refactor(app): split services and web modules
This commit is contained in:
parent
8be82e372e
commit
06ace5e67d
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod source;
|
pub mod doc;
|
||||||
pub mod point;
|
pub mod equipment;
|
||||||
pub mod tag;
|
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod doc;
|
pub mod point;
|
||||||
|
pub mod source;
|
||||||
|
pub mod tag;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::util::{
|
||||||
|
pagination::{PaginatedResponse, PaginationParams},
|
||||||
|
response::ApiErr,
|
||||||
|
};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate)]
|
||||||
|
pub struct GetEquipmentListQuery {
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub keyword: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub pagination: PaginationParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct EquipmentListItem {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub equipment: crate::model::Equipment,
|
||||||
|
pub point_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_equipment_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<GetEquipmentListQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
query.validate()?;
|
||||||
|
|
||||||
|
let total = crate::service::get_equipment_count(&state.pool, query.keyword.as_deref()).await?;
|
||||||
|
let data = crate::service::get_equipment_paginated(
|
||||||
|
&state.pool,
|
||||||
|
query.keyword.as_deref(),
|
||||||
|
query.pagination.page_size,
|
||||||
|
query.pagination.offset(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(PaginatedResponse::new(
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
query.pagination.page,
|
||||||
|
query.pagination.page_size,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_equipment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(equipment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
let equipment = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
|
||||||
|
|
||||||
|
match equipment {
|
||||||
|
Some(item) => Ok(Json(item)),
|
||||||
|
None => Err(ApiErr::NotFound("Equipment not found".to_string(), None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_equipment_points(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(equipment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
let exists = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let points = crate::service::get_points_by_equipment_id(&state.pool, equipment_id).await?;
|
||||||
|
Ok(Json(points))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub struct CreateEquipmentReq {
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub code: String,
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub name: String,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub struct UpdateEquipmentReq {
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub code: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_equipment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateEquipmentReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
payload.validate()?;
|
||||||
|
|
||||||
|
let exists = crate::service::get_equipment_by_code(&state.pool, &payload.code).await?;
|
||||||
|
if exists.is_some() {
|
||||||
|
return Err(ApiErr::BadRequest(
|
||||||
|
"Equipment code already exists".to_string(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let equipment_id = crate::service::create_equipment(
|
||||||
|
&state.pool,
|
||||||
|
&payload.code,
|
||||||
|
&payload.name,
|
||||||
|
payload.kind.as_deref(),
|
||||||
|
payload.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"id": equipment_id,
|
||||||
|
"ok_msg": "Equipment created successfully"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_equipment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(equipment_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateEquipmentReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
payload.validate()?;
|
||||||
|
|
||||||
|
if payload.code.is_none()
|
||||||
|
&& payload.name.is_none()
|
||||||
|
&& payload.kind.is_none()
|
||||||
|
&& payload.description.is_none()
|
||||||
|
{
|
||||||
|
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let exists = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(code) = payload.code.as_deref() {
|
||||||
|
let duplicate = crate::service::get_equipment_by_code(&state.pool, code).await?;
|
||||||
|
if duplicate
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|item| item.id != equipment_id)
|
||||||
|
{
|
||||||
|
return Err(ApiErr::BadRequest(
|
||||||
|
"Equipment code already exists".to_string(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::service::update_equipment(
|
||||||
|
&state.pool,
|
||||||
|
equipment_id,
|
||||||
|
payload.code.as_deref(),
|
||||||
|
payload.name.as_deref(),
|
||||||
|
payload.kind.as_deref(),
|
||||||
|
payload.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok_msg": "Equipment updated successfully"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_equipment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(equipment_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
let deleted = crate::service::delete_equipment(&state.pool, equipment_id).await?;
|
||||||
|
if !deleted {
|
||||||
|
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
@ -157,6 +157,12 @@ pub struct BatchSetPointTagsReq {
|
||||||
pub tag_id: Option<Uuid>,
|
pub tag_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate)]
|
||||||
|
pub struct BatchSetPointEquipmentReq {
|
||||||
|
pub point_ids: Vec<Uuid>,
|
||||||
|
pub equipment_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Update point metadata (name/description/unit only).
|
/// Update point metadata (name/description/unit only).
|
||||||
pub async fn update_point(
|
pub async fn update_point(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -299,6 +305,58 @@ pub async fn batch_set_point_tags(
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn batch_set_point_equipment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<BatchSetPointEquipmentReq>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
payload.validate()?;
|
||||||
|
|
||||||
|
if payload.point_ids.is_empty() {
|
||||||
|
return Err(ApiErr::BadRequest(
|
||||||
|
"point_ids cannot be empty".to_string(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = &state.pool;
|
||||||
|
|
||||||
|
if let Some(equipment_id) = payload.equipment_id {
|
||||||
|
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
|
||||||
|
.bind(equipment_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if !equipment_exists {
|
||||||
|
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_points: Vec<Uuid> = sqlx::query(r#"SELECT id FROM point WHERE id = ANY($1)"#)
|
||||||
|
.bind(&payload.point_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row: sqlx::postgres::PgRow| row.get::<Uuid, _>("id"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if existing_points.is_empty() {
|
||||||
|
return Err(ApiErr::NotFound("No valid points found".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result =
|
||||||
|
sqlx::query(r#"UPDATE point SET equipment_id = $1, updated_at = NOW() WHERE id = ANY($2)"#)
|
||||||
|
.bind(payload.equipment_id)
|
||||||
|
.bind(&existing_points)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok_msg": "Point equipment updated successfully",
|
||||||
|
"updated_count": result.rows_affected()
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete one point by id.
|
/// Delete one point by id.
|
||||||
pub async fn delete_point(
|
pub async fn delete_point(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
|
||||||
161
src/main.rs
161
src/main.rs
|
|
@ -1,28 +1,27 @@
|
||||||
mod model;
|
|
||||||
mod config;
|
mod config;
|
||||||
mod util;
|
mod connection;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod event;
|
||||||
mod handler;
|
mod handler;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod connection;
|
mod model;
|
||||||
mod event;
|
|
||||||
mod service;
|
mod service;
|
||||||
mod websocket;
|
|
||||||
mod telemetry;
|
mod telemetry;
|
||||||
use config::AppConfig;
|
mod util;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
mod websocket;
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use db::init_database;
|
|
||||||
use middleware::simple_logger;
|
|
||||||
use connection::ConnectionManager;
|
|
||||||
use event::EventManager;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, put},
|
routing::{get, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use config::AppConfig;
|
||||||
|
use connection::ConnectionManager;
|
||||||
|
use db::init_database;
|
||||||
|
use event::EventManager;
|
||||||
|
use middleware::simple_logger;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
|
|
@ -38,7 +37,9 @@ async fn main() {
|
||||||
util::log::init_logger();
|
util::log::init_logger();
|
||||||
|
|
||||||
let config = AppConfig::from_env().expect("Failed to load configuration");
|
let config = AppConfig::from_env().expect("Failed to load configuration");
|
||||||
let pool = init_database(&config.database_url).await.expect("Failed to initialize database");
|
let pool = init_database(&config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to initialize database");
|
||||||
|
|
||||||
let mut connection_manager = ConnectionManager::new();
|
let mut connection_manager = ConnectionManager::new();
|
||||||
let ws_manager = Arc::new(websocket::WebSocketManager::new());
|
let ws_manager = Arc::new(websocket::WebSocketManager::new());
|
||||||
|
|
@ -52,12 +53,10 @@ async fn main() {
|
||||||
|
|
||||||
let connection_manager = Arc::new(connection_manager);
|
let connection_manager = Arc::new(connection_manager);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Connect to all enabled sources concurrently
|
// Connect to all enabled sources concurrently
|
||||||
let sources = service::get_all_enabled_sources(&pool)
|
let sources = service::get_all_enabled_sources(&pool)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to fetch sources");
|
.expect("Failed to fetch sources");
|
||||||
|
|
||||||
// Spawn a task for each source to connect and subscribe concurrently
|
// Spawn a task for each source to connect and subscribe concurrently
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
|
|
@ -109,7 +108,7 @@ async fn main() {
|
||||||
let _ = shutdown_tx_ctrl.send(()).await;
|
let _ = shutdown_tx_ctrl.send(()).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let shutdown_signal = async move{
|
let shutdown_signal = async move {
|
||||||
let _ = shutdown_rx.recv().await;
|
let _ = shutdown_rx.recv().await;
|
||||||
tracing::info!("Received shutdown signal, closing all connections...");
|
tracing::info!("Received shutdown signal, closing all connections...");
|
||||||
connection_manager_for_shutdown.disconnect_all().await;
|
connection_manager_for_shutdown.disconnect_all().await;
|
||||||
|
|
@ -124,37 +123,104 @@ async fn main() {
|
||||||
|
|
||||||
fn build_router(state: AppState) -> Router {
|
fn build_router(state: AppState) -> Router {
|
||||||
let all_route = Router::new()
|
let all_route = Router::new()
|
||||||
.route("/api/source", get(handler::source::get_source_list).post(handler::source::create_source))
|
.route(
|
||||||
.route("/api/source/{source_id}", axum::routing::delete(handler::source::delete_source).put(handler::source::update_source))
|
"/api/source",
|
||||||
.route("/api/source/{source_id}/reconnect", axum::routing::post(handler::source::reconnect_source))
|
get(handler::source::get_source_list).post(handler::source::create_source),
|
||||||
.route("/api/source/{source_id}/browse", axum::routing::post(handler::source::browse_and_save_nodes))
|
)
|
||||||
.route("/api/source/{source_id}/node-tree", get(handler::source::get_node_tree))
|
.route(
|
||||||
.route("/api/point", get(handler::point::get_point_list))
|
"/api/source/{source_id}",
|
||||||
.route(
|
axum::routing::delete(handler::source::delete_source)
|
||||||
"/api/point/value/batch",
|
.put(handler::source::update_source),
|
||||||
axum::routing::post(handler::point::batch_set_point_value),
|
)
|
||||||
)
|
.route(
|
||||||
.route(
|
"/api/source/{source_id}/reconnect",
|
||||||
"/api/point/batch",
|
axum::routing::post(handler::source::reconnect_source),
|
||||||
axum::routing::post(handler::point::batch_create_points)
|
)
|
||||||
.delete(handler::point::batch_delete_points),
|
.route(
|
||||||
)
|
"/api/source/{source_id}/browse",
|
||||||
.route("/api/point/{point_id}/history", get(handler::point::get_point_history))
|
axum::routing::post(handler::source::browse_and_save_nodes),
|
||||||
.route("/api/point/{point_id}", get(handler::point::get_point).put(handler::point::update_point).delete(handler::point::delete_point))
|
)
|
||||||
.route("/api/point/batch/set-tags", put(handler::point::batch_set_point_tags))
|
.route(
|
||||||
.route("/api/tag", get(handler::tag::get_tag_list).post(handler::tag::create_tag))
|
"/api/source/{source_id}/node-tree",
|
||||||
.route("/api/tag/{tag_id}", get(handler::tag::get_tag_points).put(handler::tag::update_tag).delete(handler::tag::delete_tag))
|
get(handler::source::get_node_tree),
|
||||||
.route("/api/page", get(handler::page::get_page_list).post(handler::page::create_page))
|
)
|
||||||
.route("/api/page/{page_id}", get(handler::page::get_page).put(handler::page::update_page).delete(handler::page::delete_page))
|
.route("/api/point", get(handler::point::get_point_list))
|
||||||
.route("/api/logs", get(handler::log::get_logs))
|
.route(
|
||||||
.route("/api/logs/stream", get(handler::log::stream_logs))
|
"/api/point/value/batch",
|
||||||
.route("/api/docs/api-md", get(handler::doc::get_api_md));
|
axum::routing::post(handler::point::batch_set_point_value),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/point/batch",
|
||||||
|
axum::routing::post(handler::point::batch_create_points)
|
||||||
|
.delete(handler::point::batch_delete_points),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/point/{point_id}/history",
|
||||||
|
get(handler::point::get_point_history),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/point/{point_id}",
|
||||||
|
get(handler::point::get_point)
|
||||||
|
.put(handler::point::update_point)
|
||||||
|
.delete(handler::point::delete_point),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/point/batch/set-tags",
|
||||||
|
put(handler::point::batch_set_point_tags),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/point/batch/set-equipment",
|
||||||
|
put(handler::point::batch_set_point_equipment),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/equipment",
|
||||||
|
get(handler::equipment::get_equipment_list).post(handler::equipment::create_equipment),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/equipment/{equipment_id}",
|
||||||
|
get(handler::equipment::get_equipment)
|
||||||
|
.put(handler::equipment::update_equipment)
|
||||||
|
.delete(handler::equipment::delete_equipment),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/equipment/{equipment_id}/points",
|
||||||
|
get(handler::equipment::get_equipment_points),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/tag",
|
||||||
|
get(handler::tag::get_tag_list).post(handler::tag::create_tag),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/tag/{tag_id}",
|
||||||
|
get(handler::tag::get_tag_points)
|
||||||
|
.put(handler::tag::update_tag)
|
||||||
|
.delete(handler::tag::delete_tag),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page",
|
||||||
|
get(handler::page::get_page_list).post(handler::page::create_page),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page/{page_id}",
|
||||||
|
get(handler::page::get_page)
|
||||||
|
.put(handler::page::update_page)
|
||||||
|
.delete(handler::page::delete_page),
|
||||||
|
)
|
||||||
|
.route("/api/logs", get(handler::log::get_logs))
|
||||||
|
.route("/api/logs/stream", get(handler::log::stream_logs))
|
||||||
|
.route("/api/docs/api-md", get(handler::doc::get_api_md));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(all_route)
|
.merge(all_route)
|
||||||
.nest_service("/ui", ServeDir::new("web").append_index_html_on_directories(true))
|
.nest_service(
|
||||||
|
"/ui",
|
||||||
|
ServeDir::new("web").append_index_html_on_directories(true),
|
||||||
|
)
|
||||||
.route("/ws/public", get(websocket::public_websocket_handler))
|
.route("/ws/public", get(websocket::public_websocket_handler))
|
||||||
.route("/ws/client/{client_id}", get(websocket::client_websocket_handler))
|
.route(
|
||||||
|
"/ws/client/{client_id}",
|
||||||
|
get(websocket::client_websocket_handler),
|
||||||
|
)
|
||||||
.layer(axum::middleware::from_fn(simple_logger))
|
.layer(axum::middleware::from_fn(simple_logger))
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
|
|
@ -241,7 +307,8 @@ mod tray {
|
||||||
_event_loop: &ActiveEventLoop,
|
_event_loop: &ActiveEventLoop,
|
||||||
_window_id: winit::window::WindowId,
|
_window_id: winit::window::WindowId,
|
||||||
_event: winit::event::WindowEvent,
|
_event: winit::event::WindowEvent,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
event_loop.set_control_flow(ControlFlow::Wait);
|
event_loop.set_control_flow(ControlFlow::Wait);
|
||||||
|
|
|
||||||
427
src/service.rs
427
src/service.rs
|
|
@ -1,420 +1,9 @@
|
||||||
use crate::model::{PointSubscriptionInfo, Source};
|
mod equipment;
|
||||||
use sqlx::{query_as, PgPool};
|
mod point;
|
||||||
|
mod source;
|
||||||
|
mod tag;
|
||||||
|
|
||||||
pub async fn get_enabled_source(
|
pub use equipment::*;
|
||||||
pool: &PgPool,
|
pub use point::*;
|
||||||
source_id: uuid::Uuid,
|
pub use source::*;
|
||||||
) -> Result<Option<Source>, sqlx::Error> {
|
pub use tag::*;
|
||||||
query_as::<_, Source>("SELECT * FROM source WHERE id = $1 AND enabled = true")
|
|
||||||
.bind(source_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_all_enabled_sources(pool: &PgPool) -> Result<Vec<Source>, sqlx::Error> {
|
|
||||||
query_as::<_, Source>("SELECT * FROM source WHERE enabled = true")
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_point_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
point_id: uuid::Uuid,
|
|
||||||
) -> Result<Option<crate::model::Point>, sqlx::Error> {
|
|
||||||
query_as::<_, crate::model::Point>(r#"SELECT * FROM point WHERE id = $1"#)
|
|
||||||
.bind(point_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_points_grouped_by_source(
|
|
||||||
pool: &PgPool,
|
|
||||||
point_ids: &[uuid::Uuid],
|
|
||||||
) -> Result<std::collections::HashMap<uuid::Uuid, Vec<PointSubscriptionInfo>>, sqlx::Error> {
|
|
||||||
if point_ids.is_empty() {
|
|
||||||
return Ok(std::collections::HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
p.id as point_id,
|
|
||||||
n.source_id,
|
|
||||||
n.external_id
|
|
||||||
FROM point p
|
|
||||||
INNER JOIN node n ON p.node_id = n.id
|
|
||||||
WHERE p.id = ANY($1)
|
|
||||||
ORDER BY n.source_id, p.created_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(point_ids)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut result: std::collections::HashMap<uuid::Uuid, Vec<PointSubscriptionInfo>> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
|
|
||||||
for row in rows {
|
|
||||||
use sqlx::Row;
|
|
||||||
|
|
||||||
let point_id: uuid::Uuid = row.get("point_id");
|
|
||||||
let source_id: uuid::Uuid = row.get("source_id");
|
|
||||||
|
|
||||||
let info = PointSubscriptionInfo {
|
|
||||||
point_id,
|
|
||||||
external_id: row.get("external_id"),
|
|
||||||
};
|
|
||||||
|
|
||||||
result.entry(source_id).or_default().push(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_points_with_ids(
|
|
||||||
pool: &PgPool,
|
|
||||||
source_id: uuid::Uuid,
|
|
||||||
point_ids: &[uuid::Uuid],
|
|
||||||
) -> Result<Vec<PointSubscriptionInfo>, sqlx::Error> {
|
|
||||||
let rows = if point_ids.is_empty() {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
p.id as point_id,
|
|
||||||
n.external_id
|
|
||||||
FROM point p
|
|
||||||
INNER JOIN node n ON p.node_id = n.id
|
|
||||||
WHERE n.source_id = $1
|
|
||||||
ORDER BY p.created_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(source_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
p.id as point_id,
|
|
||||||
n.external_id
|
|
||||||
FROM point p
|
|
||||||
INNER JOIN node n ON p.node_id = n.id
|
|
||||||
WHERE n.source_id = $1
|
|
||||||
AND p.id = ANY($2)
|
|
||||||
ORDER BY p.created_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(source_id)
|
|
||||||
.bind(point_ids)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
use sqlx::Row;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| PointSubscriptionInfo {
|
|
||||||
point_id: row.get("point_id"),
|
|
||||||
external_id: row.get("external_id"),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Point 相关服务函数 ====================
|
|
||||||
|
|
||||||
/// 获取点位总数
|
|
||||||
pub async fn get_points_count(
|
|
||||||
pool: &PgPool,
|
|
||||||
source_id: Option<uuid::Uuid>,
|
|
||||||
) -> Result<i64, sqlx::Error> {
|
|
||||||
match source_id {
|
|
||||||
Some(source_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
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(source_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM point"#)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取分页点位列表
|
|
||||||
pub async fn get_points_paginated(
|
|
||||||
pool: &PgPool,
|
|
||||||
source_id: Option<uuid::Uuid>,
|
|
||||||
page_size: i32,
|
|
||||||
offset: u32,
|
|
||||||
) -> Result<Vec<crate::model::Point>, sqlx::Error> {
|
|
||||||
match source_id {
|
|
||||||
Some(source_id) => {
|
|
||||||
if page_size == 0 {
|
|
||||||
Ok(vec![])
|
|
||||||
} else if page_size == -1 {
|
|
||||||
sqlx::query_as::<_, crate::model::Point>(
|
|
||||||
r#"
|
|
||||||
SELECT p.*
|
|
||||||
FROM point p
|
|
||||||
INNER JOIN node n ON p.node_id = n.id
|
|
||||||
WHERE n.source_id = $1
|
|
||||||
ORDER BY p.created_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(source_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, crate::model::Point>(
|
|
||||||
r#"
|
|
||||||
SELECT p.*
|
|
||||||
FROM point p
|
|
||||||
INNER JOIN node n ON p.node_id = n.id
|
|
||||||
WHERE n.source_id = $1
|
|
||||||
ORDER BY p.created_at
|
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(source_id)
|
|
||||||
.bind(page_size as i64)
|
|
||||||
.bind(offset as i64)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if page_size == 0 {
|
|
||||||
Ok(vec![])
|
|
||||||
} else if page_size == -1 {
|
|
||||||
sqlx::query_as::<_, crate::model::Point>(
|
|
||||||
r#"
|
|
||||||
SELECT * FROM point
|
|
||||||
ORDER BY created_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, crate::model::Point>(
|
|
||||||
r#"
|
|
||||||
SELECT * FROM point
|
|
||||||
ORDER BY created_at
|
|
||||||
LIMIT $1 OFFSET $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(page_size as i64)
|
|
||||||
.bind(offset as i64)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Tag 相关服务函数 ====================
|
|
||||||
|
|
||||||
/// 获取标签总数
|
|
||||||
pub async fn get_tags_count(pool: &PgPool) -> Result<i64, sqlx::Error> {
|
|
||||||
sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM tag"#)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取分页标签列表
|
|
||||||
pub async fn get_tags_paginated(
|
|
||||||
pool: &PgPool,
|
|
||||||
page_size: i32,
|
|
||||||
offset: u32,
|
|
||||||
) -> Result<Vec<crate::model::Tag>, sqlx::Error> {
|
|
||||||
if page_size == 0 {
|
|
||||||
Ok(vec![])
|
|
||||||
} else if page_size == -1 {
|
|
||||||
sqlx::query_as::<_, crate::model::Tag>(r#"SELECT * FROM tag ORDER BY created_at"#)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, crate::model::Tag>(
|
|
||||||
r#"
|
|
||||||
SELECT * FROM tag
|
|
||||||
ORDER BY created_at
|
|
||||||
LIMIT $1 OFFSET $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset as i64)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据ID获取标签
|
|
||||||
pub async fn get_tag_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
tag_id: uuid::Uuid,
|
|
||||||
) -> Result<Option<crate::model::Tag>, sqlx::Error> {
|
|
||||||
query_as::<_, crate::model::Tag>(r#"SELECT * FROM tag WHERE id = $1"#)
|
|
||||||
.bind(tag_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取标签下的点位
|
|
||||||
pub async fn get_tag_points(
|
|
||||||
pool: &PgPool,
|
|
||||||
tag_id: uuid::Uuid,
|
|
||||||
) -> Result<Vec<crate::model::Point>, sqlx::Error> {
|
|
||||||
query_as::<_, crate::model::Point>(
|
|
||||||
r#"
|
|
||||||
SELECT *
|
|
||||||
FROM point
|
|
||||||
WHERE tag_id = $1
|
|
||||||
ORDER BY created_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(tag_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建标签
|
|
||||||
pub async fn create_tag(
|
|
||||||
pool: &PgPool,
|
|
||||||
name: &str,
|
|
||||||
description: Option<&str>,
|
|
||||||
point_ids: &[uuid::Uuid],
|
|
||||||
) -> Result<uuid::Uuid, sqlx::Error> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
let tag_id = uuid::Uuid::new_v4();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO tag (id, name, description)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(tag_id)
|
|
||||||
.bind(name)
|
|
||||||
.bind(description)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !point_ids.is_empty() {
|
|
||||||
for point_id in point_ids {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE point
|
|
||||||
SET tag_id = $1
|
|
||||||
WHERE id = $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(tag_id)
|
|
||||||
.bind(point_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(tag_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新标签
|
|
||||||
pub async fn update_tag(
|
|
||||||
pool: &PgPool,
|
|
||||||
tag_id: uuid::Uuid,
|
|
||||||
name: Option<&str>,
|
|
||||||
description: Option<&str>,
|
|
||||||
point_ids: Option<&[uuid::Uuid]>,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
// 更新基本信息
|
|
||||||
if name.is_some() || description.is_some() {
|
|
||||||
let mut updates = Vec::new();
|
|
||||||
let mut param_count = 1;
|
|
||||||
|
|
||||||
if let Some(_n) = name {
|
|
||||||
updates.push(format!("name = ${}", param_count));
|
|
||||||
param_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(_d) = description {
|
|
||||||
updates.push(format!("description = ${}", param_count));
|
|
||||||
param_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
updates.push("updated_at = NOW()".to_string());
|
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
r#"UPDATE tag SET {} WHERE id = ${}"#,
|
|
||||||
updates.join(", "),
|
|
||||||
param_count
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut query = sqlx::query(&sql);
|
|
||||||
if let Some(n) = name {
|
|
||||||
query = query.bind(n);
|
|
||||||
}
|
|
||||||
if let Some(d) = description {
|
|
||||||
query = query.bind(d);
|
|
||||||
}
|
|
||||||
query = query.bind(tag_id);
|
|
||||||
|
|
||||||
query.execute(&mut *tx).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新点位列表
|
|
||||||
if let Some(new_point_ids) = point_ids {
|
|
||||||
// 先将原属于该标签的点位移出标签
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE point
|
|
||||||
SET tag_id = NULL
|
|
||||||
WHERE tag_id = $1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(tag_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 将新点位添加到标签
|
|
||||||
for point_id in new_point_ids {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE point
|
|
||||||
SET tag_id = $1
|
|
||||||
WHERE id = $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(tag_id)
|
|
||||||
.bind(point_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除标签
|
|
||||||
pub async fn delete_tag(pool: &PgPool, tag_id: uuid::Uuid) -> Result<bool, sqlx::Error> {
|
|
||||||
let result = sqlx::query(r#"DELETE FROM tag WHERE id = $1"#)
|
|
||||||
.bind(tag_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(result.rows_affected() > 0)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
use crate::{
|
||||||
|
handler::equipment::EquipmentListItem,
|
||||||
|
model::{Equipment, Point},
|
||||||
|
};
|
||||||
|
use sqlx::{query_as, PgPool, Row};
|
||||||
|
|
||||||
|
pub async fn get_points_by_equipment_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
equipment_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<Point>, sqlx::Error> {
|
||||||
|
query_as::<_, Point>(
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM point
|
||||||
|
WHERE equipment_id = $1
|
||||||
|
ORDER BY created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(equipment_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_equipment_count(pool: &PgPool, keyword: Option<&str>) -> Result<i64, sqlx::Error> {
|
||||||
|
match keyword {
|
||||||
|
Some(keyword) => {
|
||||||
|
let like = format!("%{}%", keyword);
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM equipment
|
||||||
|
WHERE code ILIKE $1 OR name ILIKE $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(like)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM equipment"#)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_equipment_paginated(
|
||||||
|
pool: &PgPool,
|
||||||
|
keyword: Option<&str>,
|
||||||
|
page_size: i32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<EquipmentListItem>, sqlx::Error> {
|
||||||
|
let rows = match keyword {
|
||||||
|
Some(keyword) => {
|
||||||
|
let like = format!("%{}%", keyword);
|
||||||
|
if page_size == -1 {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(p.id) AS point_count
|
||||||
|
FROM equipment e
|
||||||
|
LEFT JOIN point p ON p.equipment_id = e.id
|
||||||
|
WHERE e.code ILIKE $1 OR e.name ILIKE $1
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(like)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(p.id) AS point_count
|
||||||
|
FROM equipment e
|
||||||
|
LEFT JOIN point p ON p.equipment_id = e.id
|
||||||
|
WHERE e.code ILIKE $1 OR e.name ILIKE $1
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.created_at
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(like)
|
||||||
|
.bind(page_size as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if page_size == -1 {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(p.id) AS point_count
|
||||||
|
FROM equipment e
|
||||||
|
LEFT JOIN point p ON p.equipment_id = e.id
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(p.id) AS point_count
|
||||||
|
FROM equipment e
|
||||||
|
LEFT JOIN point p ON p.equipment_id = e.id
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.created_at
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(page_size as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| EquipmentListItem {
|
||||||
|
equipment: Equipment {
|
||||||
|
id: row.get("id"),
|
||||||
|
code: row.get("code"),
|
||||||
|
name: row.get("name"),
|
||||||
|
kind: row.get("kind"),
|
||||||
|
description: row.get("description"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
},
|
||||||
|
point_count: row.get::<i64, _>("point_count"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_equipment_by_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
equipment_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<Equipment>, sqlx::Error> {
|
||||||
|
query_as::<_, Equipment>(r#"SELECT * FROM equipment WHERE id = $1"#)
|
||||||
|
.bind(equipment_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_equipment_by_code(
|
||||||
|
pool: &PgPool,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<Option<Equipment>, sqlx::Error> {
|
||||||
|
query_as::<_, Equipment>(r#"SELECT * FROM equipment WHERE code = $1"#)
|
||||||
|
.bind(code)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_equipment(
|
||||||
|
pool: &PgPool,
|
||||||
|
code: &str,
|
||||||
|
name: &str,
|
||||||
|
kind: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<uuid::Uuid, sqlx::Error> {
|
||||||
|
let equipment_id = uuid::Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO equipment (id, code, name, kind, description)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(equipment_id)
|
||||||
|
.bind(code)
|
||||||
|
.bind(name)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(description)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(equipment_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_equipment(
|
||||||
|
pool: &PgPool,
|
||||||
|
equipment_id: uuid::Uuid,
|
||||||
|
code: Option<&str>,
|
||||||
|
name: Option<&str>,
|
||||||
|
kind: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
let mut param_count = 1;
|
||||||
|
|
||||||
|
if code.is_some() {
|
||||||
|
updates.push(format!("code = ${}", param_count));
|
||||||
|
param_count += 1;
|
||||||
|
}
|
||||||
|
if name.is_some() {
|
||||||
|
updates.push(format!("name = ${}", param_count));
|
||||||
|
param_count += 1;
|
||||||
|
}
|
||||||
|
if kind.is_some() {
|
||||||
|
updates.push(format!("kind = ${}", param_count));
|
||||||
|
param_count += 1;
|
||||||
|
}
|
||||||
|
if description.is_some() {
|
||||||
|
updates.push(format!("description = ${}", param_count));
|
||||||
|
param_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push("updated_at = NOW()".to_string());
|
||||||
|
let sql = format!(
|
||||||
|
r#"UPDATE equipment SET {} WHERE id = ${}"#,
|
||||||
|
updates.join(", "),
|
||||||
|
param_count
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
if let Some(code) = code {
|
||||||
|
query = query.bind(code);
|
||||||
|
}
|
||||||
|
if let Some(name) = name {
|
||||||
|
query = query.bind(name);
|
||||||
|
}
|
||||||
|
if let Some(kind) = kind {
|
||||||
|
query = query.bind(kind);
|
||||||
|
}
|
||||||
|
if let Some(description) = description {
|
||||||
|
query = query.bind(description);
|
||||||
|
}
|
||||||
|
query = query.bind(equipment_id);
|
||||||
|
query.execute(pool).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_equipment(
|
||||||
|
pool: &PgPool,
|
||||||
|
equipment_id: uuid::Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query(r#"DELETE FROM equipment WHERE id = $1"#)
|
||||||
|
.bind(equipment_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
use crate::model::{Point, PointSubscriptionInfo};
|
||||||
|
use sqlx::{query_as, PgPool, Row};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub async fn get_point_by_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
point_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<Point>, sqlx::Error> {
|
||||||
|
query_as::<_, Point>(r#"SELECT * FROM point WHERE id = $1"#)
|
||||||
|
.bind(point_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_points_grouped_by_source(
|
||||||
|
pool: &PgPool,
|
||||||
|
point_ids: &[uuid::Uuid],
|
||||||
|
) -> Result<HashMap<uuid::Uuid, Vec<PointSubscriptionInfo>>, sqlx::Error> {
|
||||||
|
if point_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id as point_id,
|
||||||
|
n.source_id,
|
||||||
|
n.external_id
|
||||||
|
FROM point p
|
||||||
|
INNER JOIN node n ON p.node_id = n.id
|
||||||
|
WHERE p.id = ANY($1)
|
||||||
|
ORDER BY n.source_id, p.created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(point_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut result: HashMap<uuid::Uuid, Vec<PointSubscriptionInfo>> = HashMap::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let point_id: uuid::Uuid = row.get("point_id");
|
||||||
|
let source_id: uuid::Uuid = row.get("source_id");
|
||||||
|
|
||||||
|
result.entry(source_id).or_default().push(PointSubscriptionInfo {
|
||||||
|
point_id,
|
||||||
|
external_id: row.get("external_id"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_points_with_ids(
|
||||||
|
pool: &PgPool,
|
||||||
|
source_id: uuid::Uuid,
|
||||||
|
point_ids: &[uuid::Uuid],
|
||||||
|
) -> Result<Vec<PointSubscriptionInfo>, sqlx::Error> {
|
||||||
|
let rows = if point_ids.is_empty() {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id as point_id,
|
||||||
|
n.external_id
|
||||||
|
FROM point p
|
||||||
|
INNER JOIN node n ON p.node_id = n.id
|
||||||
|
WHERE n.source_id = $1
|
||||||
|
ORDER BY p.created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(source_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id as point_id,
|
||||||
|
n.external_id
|
||||||
|
FROM point p
|
||||||
|
INNER JOIN node n ON p.node_id = n.id
|
||||||
|
WHERE n.source_id = $1
|
||||||
|
AND p.id = ANY($2)
|
||||||
|
ORDER BY p.created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(source_id)
|
||||||
|
.bind(point_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| PointSubscriptionInfo {
|
||||||
|
point_id: row.get("point_id"),
|
||||||
|
external_id: row.get("external_id"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_points_count(
|
||||||
|
pool: &PgPool,
|
||||||
|
source_id: Option<uuid::Uuid>,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
match source_id {
|
||||||
|
Some(source_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
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(source_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM point"#)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_points_paginated(
|
||||||
|
pool: &PgPool,
|
||||||
|
source_id: Option<uuid::Uuid>,
|
||||||
|
page_size: i32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Point>, sqlx::Error> {
|
||||||
|
match source_id {
|
||||||
|
Some(source_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
|
||||||
|
ORDER BY p.created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(source_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
|
||||||
|
ORDER BY p.created_at
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(source_id)
|
||||||
|
.bind(page_size as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if page_size == 0 {
|
||||||
|
Ok(vec![])
|
||||||
|
} else if page_size == -1 {
|
||||||
|
sqlx::query_as::<_, Point>(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM point
|
||||||
|
ORDER BY created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, Point>(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM point
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(page_size as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
use crate::model::Source;
|
||||||
|
use sqlx::{query_as, PgPool};
|
||||||
|
|
||||||
|
pub async fn get_enabled_source(
|
||||||
|
pool: &PgPool,
|
||||||
|
source_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<Source>, sqlx::Error> {
|
||||||
|
query_as::<_, Source>("SELECT * FROM source WHERE id = $1 AND enabled = true")
|
||||||
|
.bind(source_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_enabled_sources(pool: &PgPool) -> Result<Vec<Source>, sqlx::Error> {
|
||||||
|
query_as::<_, Source>("SELECT * FROM source WHERE enabled = true")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
use crate::model::{Point, Tag};
|
||||||
|
use sqlx::{query_as, PgPool};
|
||||||
|
|
||||||
|
pub async fn get_tags_count(pool: &PgPool) -> Result<i64, sqlx::Error> {
|
||||||
|
sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM tag"#)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tags_paginated(
|
||||||
|
pool: &PgPool,
|
||||||
|
page_size: i32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Tag>, sqlx::Error> {
|
||||||
|
if page_size == 0 {
|
||||||
|
Ok(vec![])
|
||||||
|
} else if page_size == -1 {
|
||||||
|
sqlx::query_as::<_, Tag>(r#"SELECT * FROM tag ORDER BY created_at"#)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, Tag>(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM tag
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tag_by_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
tag_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<Tag>, sqlx::Error> {
|
||||||
|
query_as::<_, Tag>(r#"SELECT * FROM tag WHERE id = $1"#)
|
||||||
|
.bind(tag_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tag_points(
|
||||||
|
pool: &PgPool,
|
||||||
|
tag_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<Point>, sqlx::Error> {
|
||||||
|
query_as::<_, Point>(
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM point
|
||||||
|
WHERE tag_id = $1
|
||||||
|
ORDER BY created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tag_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_tag(
|
||||||
|
pool: &PgPool,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
point_ids: &[uuid::Uuid],
|
||||||
|
) -> Result<uuid::Uuid, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let tag_id = uuid::Uuid::new_v4();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tag (id, name, description)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tag_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !point_ids.is_empty() {
|
||||||
|
for point_id in point_ids {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE point
|
||||||
|
SET tag_id = $1
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tag_id)
|
||||||
|
.bind(point_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(tag_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_tag(
|
||||||
|
pool: &PgPool,
|
||||||
|
tag_id: uuid::Uuid,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
point_ids: Option<&[uuid::Uuid]>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
if name.is_some() || description.is_some() {
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
let mut param_count = 1;
|
||||||
|
|
||||||
|
if name.is_some() {
|
||||||
|
updates.push(format!("name = ${}", param_count));
|
||||||
|
param_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if description.is_some() {
|
||||||
|
updates.push(format!("description = ${}", param_count));
|
||||||
|
param_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push("updated_at = NOW()".to_string());
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
r#"UPDATE tag SET {} WHERE id = ${}"#,
|
||||||
|
updates.join(", "),
|
||||||
|
param_count
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
if let Some(name) = name {
|
||||||
|
query = query.bind(name);
|
||||||
|
}
|
||||||
|
if let Some(description) = description {
|
||||||
|
query = query.bind(description);
|
||||||
|
}
|
||||||
|
query = query.bind(tag_id);
|
||||||
|
query.execute(&mut *tx).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_point_ids) = point_ids {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE point
|
||||||
|
SET tag_id = NULL
|
||||||
|
WHERE tag_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tag_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for point_id in new_point_ids {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE point
|
||||||
|
SET tag_id = $1
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tag_id)
|
||||||
|
.bind(point_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag(pool: &PgPool, tag_id: uuid::Uuid) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query(r#"DELETE FROM tag WHERE id = $1"#)
|
||||||
|
.bind(tag_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
const docStatus = document.getElementById('docStatus');
|
|
||||||
const docContent = document.getElementById('docContent');
|
|
||||||
|
|
||||||
function setDocStatus(text) {
|
|
||||||
docStatus.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadApiMarkdown() {
|
|
||||||
try {
|
|
||||||
setDocStatus('加载中...');
|
|
||||||
const response = await fetch('/api/docs/api-md');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const text = await response.text();
|
|
||||||
docContent.textContent = text || 'API.md 为空';
|
|
||||||
setDocStatus('已加载');
|
|
||||||
} catch (error) {
|
|
||||||
docContent.textContent = `加载 API.md 失败\n\n${error.message || 'unknown error'}`;
|
|
||||||
setDocStatus('加载失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadApiMarkdown();
|
|
||||||
945
web/app.js
945
web/app.js
|
|
@ -1,945 +0,0 @@
|
||||||
const state = {
|
|
||||||
sources: [],
|
|
||||||
selectedSourceId: null,
|
|
||||||
tree: [],
|
|
||||||
selectedNodeIds: new Set(),
|
|
||||||
logSource: null,
|
|
||||||
points: new Map(),
|
|
||||||
pointEls: new Map(),
|
|
||||||
pointsPage: 1,
|
|
||||||
pointsPageSize: 100,
|
|
||||||
pointsTotal: 0,
|
|
||||||
chartPointId: null,
|
|
||||||
chartPointName: '',
|
|
||||||
chartData: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const el = (id) => document.getElementById(id);
|
|
||||||
const statusText = el('statusText');
|
|
||||||
const sourceForm = el('sourceForm');
|
|
||||||
const sourceIdInput = el('sourceId');
|
|
||||||
const sourceName = el('sourceName');
|
|
||||||
const sourceEndpoint = el('sourceEndpoint');
|
|
||||||
const sourceEnabled = el('sourceEnabled');
|
|
||||||
const sourceSubmit = el('sourceSubmit');
|
|
||||||
const sourceReset = el('sourceReset');
|
|
||||||
const sourceList = el('sourceList');
|
|
||||||
const nodeTree = el('nodeTree');
|
|
||||||
const browseNodesBtn = el('browseNodes');
|
|
||||||
const refreshTreeBtn = el('refreshTree');
|
|
||||||
const createPointsBtn = el('createPoints');
|
|
||||||
const logView = el('logView');
|
|
||||||
const pointList = el('pointList');
|
|
||||||
const pointModal = el('pointModal');
|
|
||||||
const closeModalBtn = el('closeModal');
|
|
||||||
const selectedCount = el('selectedCount');
|
|
||||||
const prevPointsBtn = el('prevPoints');
|
|
||||||
const nextPointsBtn = el('nextPoints');
|
|
||||||
const pointsPageInfo = el('pointsPageInfo');
|
|
||||||
const openSourceFormBtn = el('openSourceForm');
|
|
||||||
const sourceModal = el('sourceModal');
|
|
||||||
const closeSourceModalBtn = el('closeSourceModal');
|
|
||||||
const chartCanvas = el('chartCanvas');
|
|
||||||
const chartTitle = el('chartTitle');
|
|
||||||
const chartSummary = el('chartSummary');
|
|
||||||
const refreshChartBtn = el('refreshChart');
|
|
||||||
const openApiDocBtn = el('openApiDoc');
|
|
||||||
const apiDocDrawer = el('apiDocDrawer');
|
|
||||||
const closeApiDocBtn = el('closeApiDoc');
|
|
||||||
const apiDocContent = el('apiDocContent');
|
|
||||||
const apiDocToc = el('apiDocToc');
|
|
||||||
|
|
||||||
let apiDocLoaded = false;
|
|
||||||
|
|
||||||
function setStatus(text) {
|
|
||||||
statusText.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value)
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll("'", ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInlineMarkdown(text) {
|
|
||||||
let html = escapeHtml(text);
|
|
||||||
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
||||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
||||||
html = html.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugifyHeading(text, used) {
|
|
||||||
const base = String(text || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/<[^>]+>/g, '')
|
|
||||||
.replace(/[^\w\u4e00-\u9fa5-]+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '') || 'section';
|
|
||||||
let slug = base;
|
|
||||||
let index = 2;
|
|
||||||
while (used.has(slug)) {
|
|
||||||
slug = `${base}-${index}`;
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
used.add(slug);
|
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMarkdown(markdown) {
|
|
||||||
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
|
|
||||||
const blocks = [];
|
|
||||||
const toc = [];
|
|
||||||
const usedHeadingIds = new Set();
|
|
||||||
let paragraph = [];
|
|
||||||
let listType = null;
|
|
||||||
let listItems = [];
|
|
||||||
let codeFence = false;
|
|
||||||
let codeLines = [];
|
|
||||||
|
|
||||||
function flushParagraph() {
|
|
||||||
if (!paragraph.length) return;
|
|
||||||
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(' '))}</p>`);
|
|
||||||
paragraph = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushList() {
|
|
||||||
if (!listItems.length) return;
|
|
||||||
const tag = listType || 'ul';
|
|
||||||
blocks.push(`<${tag}>${listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</${tag}>`);
|
|
||||||
listItems = [];
|
|
||||||
listType = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushCodeFence() {
|
|
||||||
if (!codeFence) return;
|
|
||||||
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
|
||||||
codeFence = false;
|
|
||||||
codeLines = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim().startsWith('```')) {
|
|
||||||
flushParagraph();
|
|
||||||
flushList();
|
|
||||||
if (codeFence) {
|
|
||||||
flushCodeFence();
|
|
||||||
} else {
|
|
||||||
codeFence = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codeFence) {
|
|
||||||
codeLines.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
flushParagraph();
|
|
||||||
flushList();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^---+$/.test(trimmed)) {
|
|
||||||
flushParagraph();
|
|
||||||
flushList();
|
|
||||||
blocks.push('<hr>');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
|
||||||
if (heading) {
|
|
||||||
flushParagraph();
|
|
||||||
flushList();
|
|
||||||
const level = heading[1].length;
|
|
||||||
const title = heading[2].trim();
|
|
||||||
const id = slugifyHeading(title, usedHeadingIds);
|
|
||||||
toc.push({ level, title, id });
|
|
||||||
blocks.push(`<h${level} id="${id}">${renderInlineMarkdown(title)}</h${level}>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const quote = trimmed.match(/^>\s?(.*)$/);
|
|
||||||
if (quote) {
|
|
||||||
flushParagraph();
|
|
||||||
flushList();
|
|
||||||
blocks.push(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unordered = trimmed.match(/^[-*]\s+(.*)$/);
|
|
||||||
if (unordered) {
|
|
||||||
flushParagraph();
|
|
||||||
if (listType && listType !== 'ul') flushList();
|
|
||||||
listType = 'ul';
|
|
||||||
listItems.push(unordered[1]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ordered = trimmed.match(/^\d+\.\s+(.*)$/);
|
|
||||||
if (ordered) {
|
|
||||||
flushParagraph();
|
|
||||||
if (listType && listType !== 'ol') flushList();
|
|
||||||
listType = 'ol';
|
|
||||||
listItems.push(ordered[1]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
flushList();
|
|
||||||
paragraph.push(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
flushParagraph();
|
|
||||||
flushList();
|
|
||||||
flushCodeFence();
|
|
||||||
|
|
||||||
return { html: blocks.join(''), toc };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderApiDocToc(items) {
|
|
||||||
if (!apiDocToc) return;
|
|
||||||
if (!items.length) {
|
|
||||||
apiDocToc.innerHTML = '<div class="muted">无目录</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDocToc.innerHTML = items
|
|
||||||
.map((item) => `<a class="doc-toc-item level-${item.level}" href="#${item.id}">${escapeHtml(item.title)}</a>`)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
apiDocToc.querySelectorAll('a').forEach((link) => {
|
|
||||||
link.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const targetId = link.getAttribute('href').slice(1);
|
|
||||||
const target = apiDocContent.querySelector(`#${CSS.escape(targetId)}`);
|
|
||||||
if (target) {
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadApiDoc() {
|
|
||||||
apiDocContent.innerHTML = '<p class="muted">加载中...</p>';
|
|
||||||
if (apiDocToc) {
|
|
||||||
apiDocToc.innerHTML = '<div class="muted">加载中...</div>';
|
|
||||||
}
|
|
||||||
const response = await fetch('/api/docs/api-md');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const markdown = await response.text();
|
|
||||||
const rendered = renderMarkdown(markdown);
|
|
||||||
apiDocContent.innerHTML = rendered.html;
|
|
||||||
renderApiDocToc(rendered.toc);
|
|
||||||
apiDocLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openApiDocDrawer() {
|
|
||||||
apiDocDrawer.classList.remove('hidden');
|
|
||||||
if (!apiDocLoaded) {
|
|
||||||
try {
|
|
||||||
await loadApiDoc();
|
|
||||||
} catch (err) {
|
|
||||||
apiDocContent.innerHTML = `<p style="color: var(--danger);">加载 API.md 失败</p><pre><code>${escapeHtml(err.message || 'unknown error')}</code></pre>`;
|
|
||||||
if (apiDocToc) {
|
|
||||||
apiDocToc.innerHTML = '<div class="muted">目录加载失败</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeApiDocDrawer() {
|
|
||||||
apiDocDrawer.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFetch(url, options = {}) {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
throw new Error(err || res.statusText);
|
|
||||||
}
|
|
||||||
if (res.status === 204) return null;
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSources() {
|
|
||||||
sourceList.innerHTML = '';
|
|
||||||
state.sources.forEach((source) => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'list-item';
|
|
||||||
if (state.selectedSourceId === source.id) {
|
|
||||||
item.classList.add('selected');
|
|
||||||
}
|
|
||||||
item.onclick = () => {
|
|
||||||
selectSource(source.id).catch((err) => setStatus(err.message));
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusBadge = document.createElement('span');
|
|
||||||
statusBadge.className = `badge ${source.is_connected ? '' : 'offline'}`;
|
|
||||||
statusBadge.textContent = source.is_connected ? '在线' : '离线';
|
|
||||||
|
|
||||||
const titleRow = document.createElement('div');
|
|
||||||
titleRow.className = 'row';
|
|
||||||
titleRow.innerHTML = `<strong>${source.name}</strong>`;
|
|
||||||
titleRow.appendChild(statusBadge);
|
|
||||||
|
|
||||||
const endpoint = document.createElement('div');
|
|
||||||
endpoint.className = 'muted';
|
|
||||||
endpoint.textContent = source.endpoint;
|
|
||||||
|
|
||||||
const actionRow = document.createElement('div');
|
|
||||||
actionRow.className = 'row';
|
|
||||||
|
|
||||||
const selectPointsBtn = document.createElement('button');
|
|
||||||
selectPointsBtn.textContent = '选入 Points';
|
|
||||||
selectPointsBtn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
selectSource(source.id)
|
|
||||||
.then(() => {
|
|
||||||
pointModal.classList.remove('hidden');
|
|
||||||
return loadTree();
|
|
||||||
})
|
|
||||||
.catch((err) => setStatus(err.message));
|
|
||||||
};
|
|
||||||
|
|
||||||
const editBtn = document.createElement('button');
|
|
||||||
editBtn.textContent = '编辑';
|
|
||||||
editBtn.className = 'secondary';
|
|
||||||
editBtn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
fillSourceForm(source);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reconnectBtn = document.createElement('button');
|
|
||||||
reconnectBtn.textContent = '重连';
|
|
||||||
reconnectBtn.className = 'secondary';
|
|
||||||
reconnectBtn.onclick = async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await reconnectSource(source.id, source.name);
|
|
||||||
} catch (err) {
|
|
||||||
setStatus(err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
|
||||||
deleteBtn.textContent = '删除';
|
|
||||||
deleteBtn.className = 'danger';
|
|
||||||
deleteBtn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteSource(source.id).catch((err) => setStatus(err.message));
|
|
||||||
};
|
|
||||||
|
|
||||||
actionRow.appendChild(selectPointsBtn);
|
|
||||||
actionRow.appendChild(editBtn);
|
|
||||||
actionRow.appendChild(reconnectBtn);
|
|
||||||
actionRow.appendChild(deleteBtn);
|
|
||||||
|
|
||||||
item.appendChild(titleRow);
|
|
||||||
item.appendChild(endpoint);
|
|
||||||
if (source.last_error) {
|
|
||||||
const err = document.createElement('div');
|
|
||||||
err.style.color = 'var(--danger)';
|
|
||||||
err.textContent = source.last_error;
|
|
||||||
item.appendChild(err);
|
|
||||||
}
|
|
||||||
item.appendChild(actionRow);
|
|
||||||
|
|
||||||
sourceList.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillSourceForm(source) {
|
|
||||||
sourceIdInput.value = source.id;
|
|
||||||
sourceName.value = source.name || '';
|
|
||||||
sourceEndpoint.value = source.endpoint || '';
|
|
||||||
sourceEnabled.checked = !!source.enabled;
|
|
||||||
sourceSubmit.textContent = '保存';
|
|
||||||
sourceModal.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSourceForm() {
|
|
||||||
sourceIdInput.value = '';
|
|
||||||
sourceName.value = '';
|
|
||||||
sourceEndpoint.value = '';
|
|
||||||
sourceEnabled.checked = true;
|
|
||||||
sourceSubmit.textContent = '保存';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSources() {
|
|
||||||
setStatus('加载数据源...');
|
|
||||||
const data = await apiFetch('/api/source');
|
|
||||||
state.sources = data || [];
|
|
||||||
renderSources();
|
|
||||||
setStatus('Ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectSource(sourceId) {
|
|
||||||
state.selectedSourceId = sourceId;
|
|
||||||
state.selectedNodeIds.clear();
|
|
||||||
state.pointsPage = 1;
|
|
||||||
renderSources();
|
|
||||||
renderSelectedNodes();
|
|
||||||
await loadPoints();
|
|
||||||
await loadTree();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTree() {
|
|
||||||
if (!state.selectedSourceId) {
|
|
||||||
nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('加载节点树...');
|
|
||||||
const data = await apiFetch(`/api/source/${state.selectedSourceId}/node-tree`);
|
|
||||||
state.tree = data || [];
|
|
||||||
nodeTree.innerHTML = '';
|
|
||||||
state.tree.forEach((node) => nodeTree.appendChild(renderNode(node)));
|
|
||||||
setStatus('Ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNode(node) {
|
|
||||||
const details = document.createElement('details');
|
|
||||||
details.open = false;
|
|
||||||
|
|
||||||
const summary = document.createElement('summary');
|
|
||||||
if (node.children && node.children.length) {
|
|
||||||
summary.classList.add('has-children');
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkbox = document.createElement('input');
|
|
||||||
checkbox.type = 'checkbox';
|
|
||||||
checkbox.checked = state.selectedNodeIds.has(node.id);
|
|
||||||
checkbox.onchange = () => toggleNode(node, checkbox.checked);
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'node-label';
|
|
||||||
label.textContent = `${node.display_name || node.browse_name} (${node.node_class})`;
|
|
||||||
|
|
||||||
summary.appendChild(checkbox);
|
|
||||||
summary.appendChild(label);
|
|
||||||
details.appendChild(summary);
|
|
||||||
|
|
||||||
if (node.children && node.children.length) {
|
|
||||||
node.children.forEach((child) => details.appendChild(renderNode(child)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleNode(node, checked) {
|
|
||||||
if (checked) {
|
|
||||||
state.selectedNodeIds.add(node.id);
|
|
||||||
} else {
|
|
||||||
state.selectedNodeIds.delete(node.id);
|
|
||||||
}
|
|
||||||
renderSelectedNodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSelectedNodes() {
|
|
||||||
selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPoints() {
|
|
||||||
if (!state.selectedNodeIds.size) return;
|
|
||||||
setStatus('创建 Points...');
|
|
||||||
await apiFetch('/api/point/batch', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ node_ids: Array.from(state.selectedNodeIds) }),
|
|
||||||
});
|
|
||||||
setStatus('Points 创建完成');
|
|
||||||
state.selectedNodeIds.clear();
|
|
||||||
renderSelectedNodes();
|
|
||||||
pointModal.classList.add('hidden');
|
|
||||||
await loadPoints();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function browseNodes() {
|
|
||||||
if (!state.selectedSourceId) return;
|
|
||||||
setStatus('浏览节点中...');
|
|
||||||
await apiFetch(`/api/source/${state.selectedSourceId}/browse`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
await loadTree();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSource(sourceId) {
|
|
||||||
if (!confirm('确认删除该 Source?')) return;
|
|
||||||
await apiFetch(`/api/source/${sourceId}`, { method: 'DELETE' });
|
|
||||||
if (state.selectedSourceId === sourceId) {
|
|
||||||
state.selectedSourceId = null;
|
|
||||||
nodeTree.innerHTML = '';
|
|
||||||
}
|
|
||||||
await loadSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reconnectSource(sourceId, sourceName) {
|
|
||||||
setStatus(`正在重连 ${sourceName || 'Source'}...`);
|
|
||||||
await apiFetch(`/api/source/${sourceId}/reconnect`, { method: 'POST' });
|
|
||||||
await loadSources();
|
|
||||||
if (state.selectedSourceId === sourceId) {
|
|
||||||
await loadPoints();
|
|
||||||
}
|
|
||||||
setStatus(`${sourceName || 'Source'} 重连完成`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChart(pointId, pointName) {
|
|
||||||
state.chartPointId = pointId;
|
|
||||||
state.chartPointName = pointName || '测点';
|
|
||||||
chartTitle.textContent = `${state.chartPointName} 曲线`;
|
|
||||||
chartSummary.textContent = '加载中...';
|
|
||||||
await loadPointHistory(pointId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPointHistory(pointId = state.chartPointId) {
|
|
||||||
if (!pointId) return;
|
|
||||||
const items = await apiFetch(`/api/point/${pointId}/history?limit=120`);
|
|
||||||
state.chartData = (items || []).map(normalizeChartItem).filter(Boolean);
|
|
||||||
renderChart();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePoint(pointId) {
|
|
||||||
if (!confirm('确认删除该 Point?')) return;
|
|
||||||
await apiFetch(`/api/point/${pointId}`, { method: 'DELETE' });
|
|
||||||
await loadPoints();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPoints() {
|
|
||||||
setStatus('加载 Points...');
|
|
||||||
const sourceQuery = state.selectedSourceId ? `&source_id=${state.selectedSourceId}` : '';
|
|
||||||
const page = state.pointsPage;
|
|
||||||
const pageSize = state.pointsPageSize;
|
|
||||||
const data = await apiFetch(`/api/point?page=${page}&page_size=${pageSize}${sourceQuery}`);
|
|
||||||
const items = data && data.data ? data.data : [];
|
|
||||||
|
|
||||||
state.pointsTotal = data && typeof data.total === 'number' ? data.total : items.length;
|
|
||||||
state.points.clear();
|
|
||||||
state.pointEls.clear();
|
|
||||||
pointList.innerHTML = '';
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
pointList.innerHTML = '<tr><td colspan="5" class="empty-state">暂无 Points</td></tr>';
|
|
||||||
pointsPageInfo.textContent = `${state.pointsPage} / 1`;
|
|
||||||
prevPointsBtn.disabled = true;
|
|
||||||
nextPointsBtn.disabled = true;
|
|
||||||
setStatus('Ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
const point = item.point || item;
|
|
||||||
const monitor = item.point_monitor || null;
|
|
||||||
state.points.set(point.id, { point, monitor });
|
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
if (state.chartPointId === point.id) {
|
|
||||||
tr.classList.add('active');
|
|
||||||
}
|
|
||||||
tr.onclick = () => {
|
|
||||||
openChart(point.id, point.name).catch((err) => setStatus(err.message));
|
|
||||||
};
|
|
||||||
|
|
||||||
const tdName = document.createElement('td');
|
|
||||||
tdName.innerHTML = `<div class="point-name">${point.name}</div><div class="point-id">${point.node_id}</div>`;
|
|
||||||
|
|
||||||
const tdValue = document.createElement('td');
|
|
||||||
const value = document.createElement('span');
|
|
||||||
value.className = 'point-value';
|
|
||||||
value.textContent = formatValue(monitor);
|
|
||||||
tdValue.appendChild(value);
|
|
||||||
|
|
||||||
const quality = monitor ? (monitor.quality || 'unknown').toLowerCase() : 'unknown';
|
|
||||||
const tdQuality = document.createElement('td');
|
|
||||||
const qualityBadge = document.createElement('span');
|
|
||||||
qualityBadge.className = `badge quality-${quality}`;
|
|
||||||
qualityBadge.textContent = quality.toUpperCase();
|
|
||||||
tdQuality.appendChild(qualityBadge);
|
|
||||||
|
|
||||||
const tdTime = document.createElement('td');
|
|
||||||
const ts = document.createElement('span');
|
|
||||||
ts.className = 'muted';
|
|
||||||
ts.textContent = monitor && monitor.timestamp ? monitor.timestamp : '--';
|
|
||||||
tdTime.appendChild(ts);
|
|
||||||
|
|
||||||
const tdAction = document.createElement('td');
|
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
|
||||||
deleteBtn.className = 'danger';
|
|
||||||
deleteBtn.textContent = '×';
|
|
||||||
deleteBtn.title = '删除';
|
|
||||||
deleteBtn.style.cssText = 'width:22px;height:22px;padding:0;font-size:14px;';
|
|
||||||
deleteBtn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deletePoint(point.id).catch((err) => setStatus(err.message));
|
|
||||||
};
|
|
||||||
|
|
||||||
tdAction.append(deleteBtn);
|
|
||||||
tr.append(tdName, tdValue, tdQuality, tdTime, tdAction);
|
|
||||||
pointList.appendChild(tr);
|
|
||||||
|
|
||||||
state.pointEls.set(point.id, { box: tr, value, qualityBadge, ts });
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
|
||||||
pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`;
|
|
||||||
prevPointsBtn.disabled = state.pointsPage <= 1;
|
|
||||||
nextPointsBtn.disabled = state.pointsPage >= totalPages;
|
|
||||||
setStatus('Ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSource(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const payload = {
|
|
||||||
name: sourceName.value.trim(),
|
|
||||||
endpoint: sourceEndpoint.value.trim(),
|
|
||||||
enabled: sourceEnabled.checked,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!payload.name || !payload.endpoint) return;
|
|
||||||
|
|
||||||
const id = sourceIdInput.value;
|
|
||||||
if (id) {
|
|
||||||
await apiFetch(`/api/source/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await apiFetch('/api/source', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
resetSourceForm();
|
|
||||||
sourceModal.classList.add('hidden');
|
|
||||||
await loadSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLogs() {
|
|
||||||
if (state.logSource) state.logSource.close();
|
|
||||||
const es = new EventSource('/api/logs/stream');
|
|
||||||
state.logSource = es;
|
|
||||||
|
|
||||||
es.addEventListener('log', (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
data.lines.forEach((line) => appendLog(line));
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener('error', () => {
|
|
||||||
appendLog('[log stream error]');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPointSocket() {
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data);
|
|
||||||
if (payload.type === 'PointNewValue' || payload.type === 'point_new_value') {
|
|
||||||
handlePointUpdate(payload.data);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore invalid payloads
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = () => {
|
|
||||||
setTimeout(startPointSocket, 2000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointUpdate(data) {
|
|
||||||
if (!data || !data.point_id) return;
|
|
||||||
|
|
||||||
const entry = state.pointEls.get(data.point_id);
|
|
||||||
if (entry) {
|
|
||||||
const quality = (data.quality || 'unknown').toLowerCase();
|
|
||||||
entry.qualityBadge.className = `badge quality-${quality}`;
|
|
||||||
entry.qualityBadge.textContent = quality.toUpperCase();
|
|
||||||
entry.value.textContent = formatValue(data);
|
|
||||||
entry.ts.textContent = data.timestamp || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.chartPointId === data.point_id) {
|
|
||||||
appendChartPoint(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(monitor) {
|
|
||||||
if (!monitor) return '--';
|
|
||||||
if (monitor.value_text) return monitor.value_text;
|
|
||||||
if (monitor.value === null || monitor.value === undefined) return '--';
|
|
||||||
if (typeof monitor.value === 'string') return monitor.value;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(monitor.value);
|
|
||||||
} catch {
|
|
||||||
return String(monitor.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendLog(line) {
|
|
||||||
const atBottom = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - 10;
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'log-line';
|
|
||||||
|
|
||||||
const parsed = parseLogLine(line);
|
|
||||||
if (parsed) {
|
|
||||||
const levelRaw = (parsed.level || '').toString();
|
|
||||||
const level = levelRaw.toLowerCase();
|
|
||||||
if (level) div.classList.add(`level-${level}`);
|
|
||||||
|
|
||||||
const levelSpan = document.createElement('span');
|
|
||||||
levelSpan.className = 'level';
|
|
||||||
levelSpan.textContent = levelRaw || 'LOG';
|
|
||||||
|
|
||||||
const timeSpan = document.createElement('span');
|
|
||||||
timeSpan.className = 'muted';
|
|
||||||
timeSpan.textContent = parsed.timestamp ? ` ${parsed.timestamp}` : '';
|
|
||||||
|
|
||||||
const targetSpan = document.createElement('span');
|
|
||||||
targetSpan.className = 'muted';
|
|
||||||
targetSpan.textContent = parsed.target ? ` ${parsed.target}` : '';
|
|
||||||
|
|
||||||
const msgSpan = document.createElement('span');
|
|
||||||
msgSpan.className = 'message';
|
|
||||||
msgSpan.textContent =
|
|
||||||
(parsed.fields && parsed.fields.message) ||
|
|
||||||
parsed.message ||
|
|
||||||
parsed.msg ||
|
|
||||||
line;
|
|
||||||
|
|
||||||
div.appendChild(levelSpan);
|
|
||||||
if (timeSpan.textContent) div.appendChild(timeSpan);
|
|
||||||
if (targetSpan.textContent) div.appendChild(targetSpan);
|
|
||||||
div.appendChild(msgSpan);
|
|
||||||
} else {
|
|
||||||
div.textContent = line;
|
|
||||||
}
|
|
||||||
|
|
||||||
logView.appendChild(div);
|
|
||||||
if (atBottom) {
|
|
||||||
logView.scrollTop = logView.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLogLine(line) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(trimmed);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeChartItem(item) {
|
|
||||||
if (!item) return null;
|
|
||||||
return {
|
|
||||||
timestamp: item.timestamp || '',
|
|
||||||
quality: (item.quality || 'unknown').toLowerCase(),
|
|
||||||
valueText: item.value_text || formatValue(item),
|
|
||||||
valueNumber: getNumericValue(item),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNumericValue(item) {
|
|
||||||
if (typeof item.value_number === 'number' && Number.isFinite(item.value_number)) {
|
|
||||||
return item.value_number;
|
|
||||||
}
|
|
||||||
if (typeof item.value === 'number' && Number.isFinite(item.value)) {
|
|
||||||
return item.value;
|
|
||||||
}
|
|
||||||
if (typeof item.value === 'boolean') {
|
|
||||||
return item.value ? 1 : 0;
|
|
||||||
}
|
|
||||||
if (typeof item.value_text === 'string') {
|
|
||||||
const parsed = Number(item.value_text);
|
|
||||||
if (Number.isFinite(parsed)) return parsed;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendChartPoint(item) {
|
|
||||||
const normalized = normalizeChartItem(item);
|
|
||||||
if (!normalized) return;
|
|
||||||
|
|
||||||
const last = state.chartData[state.chartData.length - 1];
|
|
||||||
if (last && last.timestamp === normalized.timestamp && last.valueText === normalized.valueText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.chartData.push(normalized);
|
|
||||||
if (state.chartData.length > 120) {
|
|
||||||
state.chartData = state.chartData.slice(-120);
|
|
||||||
}
|
|
||||||
renderChart();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChart() {
|
|
||||||
const ctx = chartCanvas.getContext('2d');
|
|
||||||
const width = chartCanvas.width;
|
|
||||||
const height = chartCanvas.height;
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
|
|
||||||
if (!state.chartPointId) {
|
|
||||||
ctx.fillStyle = '#94a3b8';
|
|
||||||
ctx.font = '14px Segoe UI';
|
|
||||||
ctx.fillText('Click a point row to view its chart', 24, 40);
|
|
||||||
chartSummary.textContent = '点击上方点位表中的某一行查看曲线';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numericPoints = state.chartData.filter((item) => typeof item.valueNumber === 'number');
|
|
||||||
if (!numericPoints.length) {
|
|
||||||
ctx.fillStyle = '#94a3b8';
|
|
||||||
ctx.font = '14px Segoe UI';
|
|
||||||
ctx.fillText('No numeric history available for charting', 24, 40);
|
|
||||||
chartSummary.textContent = state.chartData.length
|
|
||||||
? `Recent ${state.chartData.length} samples are non-numeric`
|
|
||||||
: 'No history data';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const padding = { top: 20, right: 20, bottom: 36, left: 52 };
|
|
||||||
const plotWidth = width - padding.left - padding.right;
|
|
||||||
const plotHeight = height - padding.top - padding.bottom;
|
|
||||||
const values = numericPoints.map((item) => item.valueNumber);
|
|
||||||
let min = Math.min(...values);
|
|
||||||
let max = Math.max(...values);
|
|
||||||
if (min === max) {
|
|
||||||
const delta = min === 0 ? 1 : Math.abs(min) * 0.1;
|
|
||||||
min -= delta;
|
|
||||||
max += delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.strokeStyle = '#cbd5e1';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(padding.left, padding.top);
|
|
||||||
ctx.lineTo(padding.left, height - padding.bottom);
|
|
||||||
ctx.lineTo(width - padding.right, height - padding.bottom);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = '#64748b';
|
|
||||||
ctx.font = '12px Segoe UI';
|
|
||||||
ctx.fillText(max.toFixed(2), 8, padding.top + 4);
|
|
||||||
ctx.fillText(min.toFixed(2), 8, height - padding.bottom);
|
|
||||||
|
|
||||||
for (let i = 0; i <= 4; i += 1) {
|
|
||||||
const y = padding.top + (plotHeight / 4) * i;
|
|
||||||
ctx.strokeStyle = '#e2e8f0';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(padding.left, y);
|
|
||||||
ctx.lineTo(width - padding.right, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.strokeStyle = '#2563eb';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
numericPoints.forEach((item, index) => {
|
|
||||||
const x = padding.left + (plotWidth * index) / Math.max(1, numericPoints.length - 1);
|
|
||||||
const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight;
|
|
||||||
if (index === 0) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = '#2563eb';
|
|
||||||
numericPoints.forEach((item, index) => {
|
|
||||||
const x = padding.left + (plotWidth * index) / Math.max(1, numericPoints.length - 1);
|
|
||||||
const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstLabel = numericPoints[0].timestamp || '--';
|
|
||||||
const lastLabel = numericPoints[numericPoints.length - 1].timestamp || '--';
|
|
||||||
ctx.fillStyle = '#64748b';
|
|
||||||
ctx.font = '11px Segoe UI';
|
|
||||||
ctx.fillText(firstLabel, padding.left, height - 12);
|
|
||||||
const lastWidth = ctx.measureText(lastLabel).width;
|
|
||||||
ctx.fillText(lastLabel, width - padding.right - lastWidth, height - 12);
|
|
||||||
|
|
||||||
const latest = numericPoints[numericPoints.length - 1];
|
|
||||||
chartSummary.textContent = `Latest ${numericPoints.length} points, current value ${latest.valueText || latest.valueNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceForm.addEventListener('submit', (event) => {
|
|
||||||
saveSource(event).catch((err) => setStatus(err.message));
|
|
||||||
});
|
|
||||||
sourceReset.addEventListener('click', resetSourceForm);
|
|
||||||
browseNodesBtn.addEventListener('click', () => {
|
|
||||||
browseNodes().catch((err) => setStatus(err.message));
|
|
||||||
});
|
|
||||||
refreshTreeBtn.addEventListener('click', () => {
|
|
||||||
loadTree().catch((err) => setStatus(err.message));
|
|
||||||
});
|
|
||||||
createPointsBtn.addEventListener('click', () => {
|
|
||||||
createPoints().catch((err) => setStatus(err.message));
|
|
||||||
});
|
|
||||||
closeModalBtn.addEventListener('click', () => {
|
|
||||||
pointModal.classList.add('hidden');
|
|
||||||
});
|
|
||||||
openSourceFormBtn.addEventListener('click', () => {
|
|
||||||
resetSourceForm();
|
|
||||||
sourceModal.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
closeSourceModalBtn.addEventListener('click', () => {
|
|
||||||
sourceModal.classList.add('hidden');
|
|
||||||
});
|
|
||||||
if (openApiDocBtn) {
|
|
||||||
openApiDocBtn.addEventListener('click', () => {
|
|
||||||
openApiDocDrawer().catch((err) => setStatus(err.message));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (closeApiDocBtn) {
|
|
||||||
closeApiDocBtn.addEventListener('click', closeApiDocDrawer);
|
|
||||||
}
|
|
||||||
if (apiDocDrawer) {
|
|
||||||
apiDocDrawer.addEventListener('click', (event) => {
|
|
||||||
if (event.target === apiDocDrawer) {
|
|
||||||
closeApiDocDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (apiDocDrawer && event.key === 'Escape' && !apiDocDrawer.classList.contains('hidden')) {
|
|
||||||
closeApiDocDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
refreshChartBtn.addEventListener('click', () => {
|
|
||||||
loadPointHistory().catch((err) => setStatus(err.message));
|
|
||||||
});
|
|
||||||
prevPointsBtn.addEventListener('click', () => {
|
|
||||||
if (state.pointsPage > 1) {
|
|
||||||
state.pointsPage -= 1;
|
|
||||||
loadPoints().catch((err) => setStatus(err.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
nextPointsBtn.addEventListener('click', () => {
|
|
||||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
|
||||||
if (state.pointsPage < totalPages) {
|
|
||||||
state.pointsPage += 1;
|
|
||||||
loadPoints().catch((err) => setStatus(err.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadSources().catch((err) => setStatus(err.message));
|
|
||||||
loadPoints().catch((err) => setStatus(err.message));
|
|
||||||
renderChart();
|
|
||||||
startLogs();
|
|
||||||
startPointSocket();
|
|
||||||
121
web/index.html
121
web/index.html
|
|
@ -4,12 +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=20260320b" />
|
<link rel="stylesheet" href="/ui/styles.css?v=20260323b" />
|
||||||
</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="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>
|
||||||
|
|
@ -37,11 +38,12 @@
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:30%">名称</th>
|
<th style="width:24%">名称</th>
|
||||||
<th style="width:25%">值</th>
|
<th style="width:18%">值</th>
|
||||||
<th style="width:10%">质量</th>
|
<th style="width:10%">质量</th>
|
||||||
<th style="width:30%">更新时间</th>
|
<th style="width:18%">设备/角色</th>
|
||||||
<th style="width:5%"></th>
|
<th style="width:23%">更新时间</th>
|
||||||
|
<th style="width:7%"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pointList"></tbody>
|
<tbody id="pointList"></tbody>
|
||||||
|
|
@ -58,11 +60,11 @@
|
||||||
|
|
||||||
<section class="panel bottom-right">
|
<section class="panel bottom-right">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2 id="chartTitle">测点曲线</h2>
|
<h2 id="chartTitle">点位曲线</h2>
|
||||||
<button class="secondary" id="refreshChart">刷新</button>
|
<button class="secondary" id="refreshChart">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-panel">
|
<div class="chart-panel">
|
||||||
<div class="muted" id="chartSummary">点击上方点位表中的某一行查看曲线</div>
|
<div class="muted" id="chartSummary">点击上方点位表中的一行查看曲线</div>
|
||||||
<canvas id="chartCanvas" class="chart-canvas" width="820" height="320"></canvas>
|
<canvas id="chartCanvas" class="chart-canvas" width="820" height="320"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -71,8 +73,8 @@
|
||||||
<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">
|
||||||
<h3>选择节点创建 Points</h3>
|
<h3>选择节点创建点位</h3>
|
||||||
<button class="secondary" id="closeModal">×</button>
|
<button class="secondary" id="closeModal">X</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="browseNodes">浏览并同步节点</button>
|
<button id="browseNodes">浏览并同步节点</button>
|
||||||
|
|
@ -81,7 +83,7 @@
|
||||||
<div class="tree" id="nodeTree"></div>
|
<div class="tree" id="nodeTree"></div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||||
<button id="createPoints">创建 Points</button>
|
<button id="createPoints">创建点位</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,7 +92,7 @@
|
||||||
<div class="modal-content modal-sm">
|
<div class="modal-content modal-sm">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<h3>Source 配置</h3>
|
<h3>Source 配置</h3>
|
||||||
<button class="secondary" id="closeSourceModal">×</button>
|
<button class="secondary" id="closeSourceModal">X</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="sourceForm" class="form">
|
<form id="sourceForm" class="form">
|
||||||
<input type="hidden" id="sourceId" />
|
<input type="hidden" id="sourceId" />
|
||||||
|
|
@ -114,6 +116,101 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="pointBindingModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>绑定点位</h3>
|
||||||
|
<button class="secondary" id="closePointBindingModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="pointBindingForm" class="form">
|
||||||
|
<input type="hidden" id="bindingPointId" />
|
||||||
|
<label>
|
||||||
|
点位
|
||||||
|
<input id="bindingPointName" disabled />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
设备
|
||||||
|
<select id="bindingEquipmentId"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色
|
||||||
|
<input id="bindingSignalRole" placeholder="remote_status / run_status / fault_status" />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="clearPointBinding">清空绑定</button>
|
||||||
|
<button type="submit" id="savePointBinding">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-backdrop hidden" id="equipmentDrawer">
|
||||||
|
<aside class="drawer equipment-drawer" role="dialog" aria-modal="true" aria-labelledby="equipmentTitle">
|
||||||
|
<div class="drawer-head">
|
||||||
|
<h3 id="equipmentTitle">设备管理</h3>
|
||||||
|
<button type="button" class="secondary" id="closeEquipmentDrawer">关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-body equipment-layout">
|
||||||
|
<section class="equipment-sidebar">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>设备列表</h2>
|
||||||
|
<button type="button" id="newEquipmentBtn">+ 新增</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar equipment-toolbar">
|
||||||
|
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
||||||
|
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="equipmentList"></div>
|
||||||
|
</section>
|
||||||
|
<section class="equipment-content">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>设备详情</h2>
|
||||||
|
</div>
|
||||||
|
<form id="equipmentForm" class="form equipment-form">
|
||||||
|
<input type="hidden" id="equipmentId" />
|
||||||
|
<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 class="drawer-backdrop hidden" id="apiDocDrawer">
|
<div class="drawer-backdrop hidden" id="apiDocDrawer">
|
||||||
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
|
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
|
|
@ -130,6 +227,6 @@
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/ui/app.js?v=20260320b"></script>
|
<script type="module" src="/ui/js/app.js?v=20260323b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
|
||||||
|
export function setStatus(text) {
|
||||||
|
dom.statusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(url, options = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((await response.text()) || response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withStatus(task) {
|
||||||
|
return task.catch((error) => {
|
||||||
|
setStatus(error.message || "请求失败");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { withStatus } from "./api.js";
|
||||||
|
import { openChart, renderChart } from "./chart.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js";
|
||||||
|
import {
|
||||||
|
clearPointBinding,
|
||||||
|
closeEquipmentDrawer,
|
||||||
|
loadEquipments,
|
||||||
|
openEquipmentDrawer,
|
||||||
|
resetEquipmentForm,
|
||||||
|
saveEquipment,
|
||||||
|
} from "./equipment.js";
|
||||||
|
import { startLogs, startPointSocket } from "./logs.js";
|
||||||
|
import { createPoints, loadPoints, loadTree, renderSelectedNodes, savePointBinding } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { browseNodes, loadSources, saveSource } from "./sources.js";
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
||||||
|
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
|
||||||
|
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
|
||||||
|
|
||||||
|
dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset());
|
||||||
|
dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
|
||||||
|
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
|
||||||
|
dom.newEquipmentBtn.addEventListener("click", resetEquipmentForm);
|
||||||
|
|
||||||
|
dom.browseNodesBtn.addEventListener("click", () => withStatus(browseNodes()));
|
||||||
|
dom.refreshTreeBtn.addEventListener("click", () => withStatus(loadTree()));
|
||||||
|
dom.createPointsBtn.addEventListener("click", () => withStatus(createPoints()));
|
||||||
|
dom.closeModalBtn.addEventListener("click", () => dom.pointModal.classList.add("hidden"));
|
||||||
|
|
||||||
|
dom.openSourceFormBtn.addEventListener("click", () => {
|
||||||
|
dom.sourceForm.reset();
|
||||||
|
dom.sourceId.value = "";
|
||||||
|
dom.sourceModal.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
dom.closeSourceModalBtn.addEventListener("click", () => dom.sourceModal.classList.add("hidden"));
|
||||||
|
|
||||||
|
dom.clearPointBindingBtn.addEventListener("click", () => withStatus(clearPointBinding()));
|
||||||
|
dom.closePointBindingModalBtn.addEventListener("click", () => {
|
||||||
|
dom.pointBindingModal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.openEquipmentDrawerBtn.addEventListener("click", openEquipmentDrawer);
|
||||||
|
dom.closeEquipmentDrawerBtn.addEventListener("click", closeEquipmentDrawer);
|
||||||
|
|
||||||
|
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
|
||||||
|
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
|
||||||
|
|
||||||
|
dom.refreshChartBtn.addEventListener("click", () => {
|
||||||
|
if (!state.chartPointId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
withStatus(openChart(state.chartPointId, state.chartPointName));
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.prevPointsBtn.addEventListener("click", () => {
|
||||||
|
if (state.pointsPage > 1) {
|
||||||
|
state.pointsPage -= 1;
|
||||||
|
withStatus(loadPoints());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.nextPointsBtn.addEventListener("click", () => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||||
|
if (state.pointsPage < totalPages) {
|
||||||
|
state.pointsPage += 1;
|
||||||
|
withStatus(loadPoints());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.equipmentKeyword.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
withStatus(loadEquipments());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
bindEvents();
|
||||||
|
renderSelectedNodes();
|
||||||
|
renderChart();
|
||||||
|
startLogs();
|
||||||
|
startPointSocket();
|
||||||
|
|
||||||
|
await withStatus(loadSources());
|
||||||
|
await withStatus(loadEquipments());
|
||||||
|
await withStatus(loadPoints());
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
export async function openChart(pointId, pointName) {
|
||||||
|
state.chartPointId = pointId;
|
||||||
|
state.chartPointName = pointName || "点位";
|
||||||
|
dom.chartTitle.textContent = `${state.chartPointName} 曲线`;
|
||||||
|
|
||||||
|
const items = await apiFetch(`/api/point/${pointId}/history?limit=120`);
|
||||||
|
state.chartData = (items || [])
|
||||||
|
.map((item) => ({
|
||||||
|
timestamp: item.timestamp || "",
|
||||||
|
valueNumber: typeof item.value_number === "number" ? item.value_number : null,
|
||||||
|
valueText: item.value_text || "",
|
||||||
|
}))
|
||||||
|
.filter((item) => item.valueNumber !== null);
|
||||||
|
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderChart() {
|
||||||
|
const ctx = dom.chartCanvas.getContext("2d");
|
||||||
|
const width = dom.chartCanvas.width;
|
||||||
|
const height = dom.chartCanvas.height;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (!state.chartData.length) {
|
||||||
|
ctx.fillStyle = "#94a3b8";
|
||||||
|
ctx.font = "14px Segoe UI";
|
||||||
|
ctx.fillText("Click a point row to view its chart", 24, 40);
|
||||||
|
dom.chartSummary.textContent = "点击上方点位表中的一行查看曲线";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = state.chartData.map((item) => item.valueNumber);
|
||||||
|
let min = Math.min(...values);
|
||||||
|
let max = Math.max(...values);
|
||||||
|
if (min === max) {
|
||||||
|
min -= 1;
|
||||||
|
max += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = { top: 20, right: 20, bottom: 36, left: 52 };
|
||||||
|
const plotWidth = width - padding.left - padding.right;
|
||||||
|
const plotHeight = height - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
ctx.strokeStyle = "#2563eb";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
state.chartData.forEach((item, index) => {
|
||||||
|
const x = padding.left + (plotWidth * index) / Math.max(1, state.chartData.length - 1);
|
||||||
|
const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight;
|
||||||
|
if (index === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const latest = state.chartData[state.chartData.length - 1];
|
||||||
|
dom.chartSummary.textContent = `最近 ${state.chartData.length} 个点,当前值 ${latest.valueText || latest.valueNumber}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return text
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\u4e00-\u9fa5]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdown(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const blocks = [];
|
||||||
|
const headings = [];
|
||||||
|
let inCode = false;
|
||||||
|
let codeBuffer = [];
|
||||||
|
let paragraph = [];
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (!paragraph.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push(`<p>${escapeHtml(paragraph.join(" "))}</p>`);
|
||||||
|
paragraph = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushCode = () => {
|
||||||
|
if (!codeBuffer.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push(`<pre><code>${escapeHtml(codeBuffer.join("\n"))}</code></pre>`);
|
||||||
|
codeBuffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.startsWith("```")) {
|
||||||
|
if (inCode) {
|
||||||
|
flushCode();
|
||||||
|
} else {
|
||||||
|
flushParagraph();
|
||||||
|
}
|
||||||
|
inCode = !inCode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCode) {
|
||||||
|
codeBuffer.push(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,4})\s+(.*)$/);
|
||||||
|
if (heading) {
|
||||||
|
flushParagraph();
|
||||||
|
const level = heading[1].length;
|
||||||
|
const textValue = heading[2].trim();
|
||||||
|
const id = slugify(textValue);
|
||||||
|
headings.push({ level, text: textValue, id });
|
||||||
|
blocks.push(`<h${level} id="${id}">${escapeHtml(textValue)}</h${level}>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
flushParagraph();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraph.push(line.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
flushCode();
|
||||||
|
|
||||||
|
return { html: blocks.join(""), headings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadApiDoc() {
|
||||||
|
const text = await apiFetch("/api/docs/api-md");
|
||||||
|
const { html, headings } = parseMarkdown(text || "");
|
||||||
|
|
||||||
|
dom.apiDocContent.innerHTML = html || "<p>API.md 为空</p>";
|
||||||
|
dom.apiDocToc.innerHTML = headings.length
|
||||||
|
? headings
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<a class="doc-toc-item level-${item.level}" href="#${item.id}">${escapeHtml(item.text)}</a>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: "<div class=\"muted\">未解析到标题</div>";
|
||||||
|
|
||||||
|
dom.apiDocToc.querySelectorAll("a").forEach((link) => {
|
||||||
|
link.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = link.getAttribute("href")?.slice(1);
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dom.apiDocContent.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
state.apiDocLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openApiDocDrawer() {
|
||||||
|
dom.apiDocDrawer.classList.remove("hidden");
|
||||||
|
if (!state.apiDocLoaded) {
|
||||||
|
await loadApiDoc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeApiDocDrawer() {
|
||||||
|
dom.apiDocDrawer.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
export const dom = {
|
||||||
|
statusText: byId("statusText"),
|
||||||
|
sourceList: byId("sourceList"),
|
||||||
|
nodeTree: byId("nodeTree"),
|
||||||
|
pointList: byId("pointList"),
|
||||||
|
pointsPageInfo: byId("pointsPageInfo"),
|
||||||
|
selectedCount: byId("selectedCount"),
|
||||||
|
logView: byId("logView"),
|
||||||
|
chartCanvas: byId("chartCanvas"),
|
||||||
|
chartTitle: byId("chartTitle"),
|
||||||
|
chartSummary: byId("chartSummary"),
|
||||||
|
pointModal: byId("pointModal"),
|
||||||
|
sourceModal: byId("sourceModal"),
|
||||||
|
pointBindingModal: byId("pointBindingModal"),
|
||||||
|
equipmentDrawer: byId("equipmentDrawer"),
|
||||||
|
apiDocDrawer: byId("apiDocDrawer"),
|
||||||
|
sourceForm: byId("sourceForm"),
|
||||||
|
sourceId: byId("sourceId"),
|
||||||
|
sourceName: byId("sourceName"),
|
||||||
|
sourceEndpoint: byId("sourceEndpoint"),
|
||||||
|
sourceEnabled: byId("sourceEnabled"),
|
||||||
|
sourceResetBtn: byId("sourceReset"),
|
||||||
|
equipmentForm: byId("equipmentForm"),
|
||||||
|
equipmentId: byId("equipmentId"),
|
||||||
|
equipmentCode: byId("equipmentCode"),
|
||||||
|
equipmentName: byId("equipmentName"),
|
||||||
|
equipmentKind: byId("equipmentKind"),
|
||||||
|
equipmentDescription: byId("equipmentDescription"),
|
||||||
|
equipmentResetBtn: byId("equipmentReset"),
|
||||||
|
equipmentKeyword: byId("equipmentKeyword"),
|
||||||
|
equipmentList: byId("equipmentList"),
|
||||||
|
equipmentPointList: byId("equipmentPointList"),
|
||||||
|
pointBindingForm: byId("pointBindingForm"),
|
||||||
|
bindingPointId: byId("bindingPointId"),
|
||||||
|
bindingPointName: byId("bindingPointName"),
|
||||||
|
bindingEquipmentId: byId("bindingEquipmentId"),
|
||||||
|
bindingSignalRole: byId("bindingSignalRole"),
|
||||||
|
apiDocToc: byId("apiDocToc"),
|
||||||
|
apiDocContent: byId("apiDocContent"),
|
||||||
|
openEquipmentDrawerBtn: byId("openEquipmentDrawer"),
|
||||||
|
closeEquipmentDrawerBtn: byId("closeEquipmentDrawer"),
|
||||||
|
openApiDocBtn: byId("openApiDoc"),
|
||||||
|
closeApiDocBtn: byId("closeApiDoc"),
|
||||||
|
refreshChartBtn: byId("refreshChart"),
|
||||||
|
prevPointsBtn: byId("prevPoints"),
|
||||||
|
nextPointsBtn: byId("nextPoints"),
|
||||||
|
refreshEquipmentBtn: byId("refreshEquipmentBtn"),
|
||||||
|
newEquipmentBtn: byId("newEquipmentBtn"),
|
||||||
|
browseNodesBtn: byId("browseNodes"),
|
||||||
|
refreshTreeBtn: byId("refreshTree"),
|
||||||
|
createPointsBtn: byId("createPoints"),
|
||||||
|
closeModalBtn: byId("closeModal"),
|
||||||
|
openSourceFormBtn: byId("openSourceForm"),
|
||||||
|
closeSourceModalBtn: byId("closeSourceModal"),
|
||||||
|
clearPointBindingBtn: byId("clearPointBinding"),
|
||||||
|
closePointBindingModalBtn: byId("closePointBindingModal"),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { loadPoints, openPointBinding } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function equipmentOf(item) {
|
||||||
|
return item && item.equipment ? item.equipment : item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEquipmentOptions(selected = "") {
|
||||||
|
const options = ['<option value="">未绑定</option>'];
|
||||||
|
state.equipments.forEach((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
const isSelected = equipment.id === selected ? "selected" : "";
|
||||||
|
options.push(
|
||||||
|
`<option value="${equipment.id}" ${isSelected}>${equipment.code} / ${equipment.name}</option>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
dom.bindingEquipmentId.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetEquipmentForm() {
|
||||||
|
state.selectedEquipmentId = null;
|
||||||
|
dom.equipmentForm.reset();
|
||||||
|
dom.equipmentId.value = "";
|
||||||
|
dom.equipmentPointList.innerHTML =
|
||||||
|
'<tr><td colspan="3" class="empty-state">请选择设备</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEquipments() {
|
||||||
|
dom.equipmentList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.equipments.length) {
|
||||||
|
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">暂无设备</div></div>';
|
||||||
|
dom.equipmentPointList.innerHTML =
|
||||||
|
'<tr><td colspan="3" class="empty-state">暂无设备点位</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.equipments.forEach((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = `list-item ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${equipment.code}</strong>
|
||||||
|
<span class="badge">${item.point_count ?? 0} pts</span>
|
||||||
|
</div>
|
||||||
|
<div>${equipment.name}</div>
|
||||||
|
<div class="muted">${equipment.kind || "未设置类型"}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.addEventListener("click", () => {
|
||||||
|
state.selectedEquipmentId = equipment.id;
|
||||||
|
dom.equipmentId.value = equipment.id || "";
|
||||||
|
dom.equipmentCode.value = equipment.code || "";
|
||||||
|
dom.equipmentName.value = equipment.name || "";
|
||||||
|
dom.equipmentKind.value = equipment.kind || "";
|
||||||
|
dom.equipmentDescription.value = equipment.description || "";
|
||||||
|
renderEquipments();
|
||||||
|
loadEquipmentPoints(equipment.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "row";
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "danger";
|
||||||
|
deleteBtn.textContent = "删除";
|
||||||
|
deleteBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deleteEquipment(equipment.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
box.appendChild(actions);
|
||||||
|
dom.equipmentList.appendChild(box);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEquipments() {
|
||||||
|
const keyword = dom.equipmentKeyword.value.trim();
|
||||||
|
const query = keyword
|
||||||
|
? `?page=1&page_size=-1&keyword=${encodeURIComponent(keyword)}`
|
||||||
|
: "?page=1&page_size=-1";
|
||||||
|
const data = await apiFetch(`/api/equipment${query}`);
|
||||||
|
state.equipments = data.data || [];
|
||||||
|
state.equipmentMap = new Map(
|
||||||
|
state.equipments.map((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
return [equipment.id, equipment];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderEquipmentOptions();
|
||||||
|
renderEquipments();
|
||||||
|
|
||||||
|
if (state.selectedEquipmentId && state.equipmentMap.has(state.selectedEquipmentId)) {
|
||||||
|
await loadEquipmentPoints(state.selectedEquipmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEquipmentPoints(equipmentId) {
|
||||||
|
const points = await apiFetch(`/api/equipment/${equipmentId}/points`);
|
||||||
|
dom.equipmentPointList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!points.length) {
|
||||||
|
dom.equipmentPointList.innerHTML =
|
||||||
|
'<tr><td colspan="3" class="empty-state">该设备下暂无点位</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
points.forEach((point) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<div class="point-name">${point.name}</div>
|
||||||
|
<div class="point-id">${point.node_id}</div>
|
||||||
|
</td>
|
||||||
|
<td>${point.signal_role || "--"}</td>
|
||||||
|
<td></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const actionCell = tr.lastElementChild;
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "secondary";
|
||||||
|
editBtn.textContent = "编辑绑定";
|
||||||
|
editBtn.addEventListener("click", () => openPointBinding(point));
|
||||||
|
|
||||||
|
const clearBtn = document.createElement("button");
|
||||||
|
clearBtn.className = "secondary";
|
||||||
|
clearBtn.textContent = "解绑";
|
||||||
|
clearBtn.addEventListener("click", () => {
|
||||||
|
clearPointBinding(point.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actionCell.append(editBtn, clearBtn);
|
||||||
|
dom.equipmentPointList.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEquipment(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code: dom.equipmentCode.value.trim(),
|
||||||
|
name: dom.equipmentName.value.trim(),
|
||||||
|
kind: dom.equipmentKind.value.trim() || null,
|
||||||
|
description: dom.equipmentDescription.value.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = dom.equipmentId.value;
|
||||||
|
await apiFetch(id ? `/api/equipment/${id}` : "/api/equipment", {
|
||||||
|
method: id ? "PUT" : "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEquipment(equipmentId) {
|
||||||
|
if (!window.confirm("确认删除该设备?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/equipment/${equipmentId}`, { method: "DELETE" });
|
||||||
|
resetEquipmentForm();
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openEquipmentDrawer() {
|
||||||
|
dom.equipmentDrawer.classList.remove("hidden");
|
||||||
|
loadEquipments().catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeEquipmentDrawer() {
|
||||||
|
dom.equipmentDrawer.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPointBinding(pointId = dom.bindingPointId.value) {
|
||||||
|
await apiFetch(`/api/point/${pointId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ equipment_id: null, signal_role: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.pointBindingModal.classList.add("hidden");
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { formatValue } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
export function appendLog(line) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "log-line";
|
||||||
|
div.textContent = line;
|
||||||
|
dom.logView.appendChild(div);
|
||||||
|
dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLogs() {
|
||||||
|
if (state.logSource) {
|
||||||
|
state.logSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.logSource = new EventSource("/api/logs/stream");
|
||||||
|
state.logSource.addEventListener("log", (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
(data.lines || []).forEach(appendLog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPointSocket() {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
||||||
|
state.pointSocket = ws;
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
if (payload.type !== "PointNewValue" && payload.type !== "point_new_value") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = payload.data;
|
||||||
|
const entry = state.pointEls.get(data.point_id);
|
||||||
|
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 || "--";
|
||||||
|
} catch {
|
||||||
|
// ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
window.setTimeout(startPointSocket, 2000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { openChart } from "./chart.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { loadEquipments, renderEquipmentOptions } from "./equipment.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
export function formatValue(monitor) {
|
||||||
|
if (!monitor) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
if (monitor.value_text) {
|
||||||
|
return monitor.value_text;
|
||||||
|
}
|
||||||
|
if (monitor.value === null || monitor.value === undefined) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return typeof monitor.value === "string" ? monitor.value : JSON.stringify(monitor.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSelectedNodes() {
|
||||||
|
dom.selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node) {
|
||||||
|
const details = document.createElement("details");
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
summary.classList.add("has-children");
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.checked = state.selectedNodeIds.has(node.id);
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
state.selectedNodeIds.add(node.id);
|
||||||
|
} else {
|
||||||
|
state.selectedNodeIds.delete(node.id);
|
||||||
|
}
|
||||||
|
renderSelectedNodes();
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "node-label";
|
||||||
|
label.textContent = `${node.display_name || node.browse_name} (${node.node_class})`;
|
||||||
|
|
||||||
|
summary.append(checkbox, label);
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
(node.children || []).forEach((child) => {
|
||||||
|
details.appendChild(renderNode(child));
|
||||||
|
});
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTree() {
|
||||||
|
if (!state.selectedSourceId) {
|
||||||
|
dom.nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await apiFetch(`/api/source/${state.selectedSourceId}/node-tree`);
|
||||||
|
dom.nodeTree.innerHTML = "";
|
||||||
|
(data || []).forEach((node) => dom.nodeTree.appendChild(renderNode(node)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPoints() {
|
||||||
|
if (!state.selectedNodeIds.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch("/api/point/batch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ node_ids: Array.from(state.selectedNodeIds) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.selectedNodeIds.clear();
|
||||||
|
renderSelectedNodes();
|
||||||
|
dom.pointModal.classList.add("hidden");
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPoints() {
|
||||||
|
const sourceQuery = state.selectedSourceId ? `&source_id=${state.selectedSourceId}` : "";
|
||||||
|
const data = await apiFetch(
|
||||||
|
`/api/point?page=${state.pointsPage}&page_size=${state.pointsPageSize}${sourceQuery}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = data.data || [];
|
||||||
|
state.pointsTotal = typeof data.total === "number" ? data.total : items.length;
|
||||||
|
state.pointEls.clear();
|
||||||
|
dom.pointList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
dom.pointList.innerHTML = '<tr><td colspan="6" class="empty-state">暂无点位</td></tr>';
|
||||||
|
dom.pointsPageInfo.textContent = `${state.pointsPage} / 1`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const point = item.point || item;
|
||||||
|
const monitor = item.point_monitor || null;
|
||||||
|
const equipment = point.equipment_id ? state.equipmentMap.get(point.equipment_id) : null;
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
tr.addEventListener("click", () => {
|
||||||
|
openChart(point.id, point.name).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<div class="point-name">${point.name}</div>
|
||||||
|
<div class="point-id">${point.node_id}</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="point-value">${formatValue(monitor)}</span></td>
|
||||||
|
<td><span class="badge quality-${(monitor?.quality || "unknown").toLowerCase()}">${(monitor?.quality || "unknown").toUpperCase()}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="point-meta">
|
||||||
|
<div>${equipment ? equipment.name : '<span class="muted">未绑定</span>'}</div>
|
||||||
|
<div class="point-role">${point.signal_role || "--"}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="muted">${monitor?.timestamp || "--"}</span></td>
|
||||||
|
<td></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const actionCell = tr.lastElementChild;
|
||||||
|
|
||||||
|
const bindBtn = document.createElement("button");
|
||||||
|
bindBtn.className = "secondary";
|
||||||
|
bindBtn.textContent = "绑定";
|
||||||
|
bindBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openPointBinding(point);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "danger";
|
||||||
|
deleteBtn.textContent = "删除";
|
||||||
|
deleteBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deletePoint(point.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actionCell.append(bindBtn, deleteBtn);
|
||||||
|
dom.pointList.appendChild(tr);
|
||||||
|
|
||||||
|
state.pointEls.set(point.id, {
|
||||||
|
row: tr,
|
||||||
|
value: tr.querySelector(".point-value"),
|
||||||
|
quality: tr.querySelector(".badge"),
|
||||||
|
time: tr.querySelector("td:nth-child(5) .muted"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||||
|
dom.pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPointBinding(point) {
|
||||||
|
dom.bindingPointId.value = point.id;
|
||||||
|
dom.bindingPointName.value = point.name || "";
|
||||||
|
dom.bindingSignalRole.value = point.signal_role || "";
|
||||||
|
renderEquipmentOptions(point.equipment_id || "");
|
||||||
|
dom.pointBindingModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePointBinding(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
await apiFetch(`/api/point/${dom.bindingPointId.value}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
equipment_id: dom.bindingEquipmentId.value || null,
|
||||||
|
signal_role: dom.bindingSignalRole.value.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.pointBindingModal.classList.add("hidden");
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePoint(pointId) {
|
||||||
|
if (!window.confirm("确认删除该点位?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/point/${pointId}`, { method: "DELETE" });
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { loadPoints, loadTree, renderSelectedNodes } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
export function renderSources() {
|
||||||
|
dom.sourceList.innerHTML = "";
|
||||||
|
|
||||||
|
state.sources.forEach((source) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `list-item ${state.selectedSourceId === source.id ? "selected" : ""}`;
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${source.name}</strong>
|
||||||
|
<span class="badge ${source.is_connected ? "" : "offline"}">${source.is_connected ? "在线" : "离线"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted">${source.endpoint}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
selectSource(source.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "row";
|
||||||
|
|
||||||
|
const selectBtn = document.createElement("button");
|
||||||
|
selectBtn.textContent = "选入点位";
|
||||||
|
selectBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
selectSource(source.id)
|
||||||
|
.then(() => {
|
||||||
|
dom.pointModal.classList.remove("hidden");
|
||||||
|
return loadTree();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "secondary";
|
||||||
|
editBtn.textContent = "编辑";
|
||||||
|
editBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
dom.sourceId.value = source.id;
|
||||||
|
dom.sourceName.value = source.name || "";
|
||||||
|
dom.sourceEndpoint.value = source.endpoint || "";
|
||||||
|
dom.sourceEnabled.checked = !!source.enabled;
|
||||||
|
dom.sourceModal.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
const reconnectBtn = document.createElement("button");
|
||||||
|
reconnectBtn.className = "secondary";
|
||||||
|
reconnectBtn.textContent = "重连";
|
||||||
|
reconnectBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
reconnectSource(source.id, source.name).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "danger";
|
||||||
|
deleteBtn.textContent = "删除";
|
||||||
|
deleteBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deleteSource(source.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(selectBtn, editBtn, reconnectBtn, deleteBtn);
|
||||||
|
item.appendChild(actions);
|
||||||
|
dom.sourceList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSources() {
|
||||||
|
state.sources = await apiFetch("/api/source");
|
||||||
|
renderSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectSource(sourceId) {
|
||||||
|
state.selectedSourceId = sourceId;
|
||||||
|
state.selectedNodeIds.clear();
|
||||||
|
state.pointsPage = 1;
|
||||||
|
renderSources();
|
||||||
|
renderSelectedNodes();
|
||||||
|
await loadPoints();
|
||||||
|
await loadTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSource(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: dom.sourceName.value.trim(),
|
||||||
|
endpoint: dom.sourceEndpoint.value.trim(),
|
||||||
|
enabled: dom.sourceEnabled.checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = dom.sourceId.value;
|
||||||
|
await apiFetch(id ? `/api/source/${id}` : "/api/source", {
|
||||||
|
method: id ? "PUT" : "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.sourceModal.classList.add("hidden");
|
||||||
|
dom.sourceForm.reset();
|
||||||
|
await loadSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSource(sourceId) {
|
||||||
|
if (!window.confirm("确认删除该 Source?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/source/${sourceId}`, { method: "DELETE" });
|
||||||
|
await loadSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconnectSource(sourceId, name) {
|
||||||
|
dom.statusText.textContent = `正在重连 ${name || "Source"}...`;
|
||||||
|
await apiFetch(`/api/source/${sourceId}/reconnect`, { method: "POST" });
|
||||||
|
await loadSources();
|
||||||
|
dom.statusText.textContent = "Ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browseNodes() {
|
||||||
|
if (!state.selectedSourceId) {
|
||||||
|
throw new Error("请先选择数据源");
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/source/${state.selectedSourceId}/browse`, { method: "POST" });
|
||||||
|
await loadTree();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
export const state = {
|
||||||
|
sources: [],
|
||||||
|
equipments: [],
|
||||||
|
equipmentMap: new Map(),
|
||||||
|
selectedEquipmentId: null,
|
||||||
|
selectedSourceId: null,
|
||||||
|
selectedNodeIds: new Set(),
|
||||||
|
pointsPage: 1,
|
||||||
|
pointsPageSize: 100,
|
||||||
|
pointsTotal: 0,
|
||||||
|
pointEls: new Map(),
|
||||||
|
chartPointId: null,
|
||||||
|
chartPointName: "",
|
||||||
|
chartData: [],
|
||||||
|
logSource: null,
|
||||||
|
pointSocket: null,
|
||||||
|
apiDocLoaded: false,
|
||||||
|
};
|
||||||
|
|
@ -373,7 +373,8 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
.form input[type="url"],
|
.form input[type="url"],
|
||||||
.form input[type="password"],
|
.form input[type="password"],
|
||||||
.form input[type="number"],
|
.form input[type="number"],
|
||||||
.form input:not([type]) {
|
.form input:not([type]),
|
||||||
|
.form select {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
@ -383,7 +384,8 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form input:focus {
|
.form input:focus,
|
||||||
|
.form select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
|
||||||
|
|
@ -614,6 +616,67 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.equipment-drawer {
|
||||||
|
width: min(1120px, 96vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-layout {
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-sidebar,
|
||||||
|
.equipment-content {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-sidebar {
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-toolbar {
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-toolbar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-form {
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-points {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table td,
|
||||||
|
.compact-table th {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
.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);
|
||||||
|
|
@ -819,6 +882,8 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
.panel.bottom-right { grid-column: 1; grid-row: 4; min-height: 320px; }
|
.panel.bottom-right { grid-column: 1; grid-row: 4; 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-sidebar { border-right: none; border-bottom: 1px solid var(--border-light); }
|
||||||
.doc-toc { border-right: none; border-bottom: 1px solid var(--border-light); max-height: 180px; }
|
.doc-toc { border-right: none; border-bottom: 1px solid var(--border-light); max-height: 180px; }
|
||||||
.doc-view { padding: 0; }
|
.doc-view { padding: 0; }
|
||||||
.doc-card { border-left: none; border-right: none; }
|
.doc-card { border-left: none; border-right: none; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue