diff --git a/src/handler.rs b/src/handler.rs index 2bd23dc..a0382b6 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,6 +1,7 @@ -pub mod source; -pub mod point; -pub mod tag; +pub mod doc; +pub mod equipment; pub mod log; pub mod page; -pub mod doc; +pub mod point; +pub mod source; +pub mod tag; diff --git a/src/handler/equipment.rs b/src/handler/equipment.rs new file mode 100644 index 0000000..3557076 --- /dev/null +++ b/src/handler/equipment.rs @@ -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, + #[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, + Query(query): Query, +) -> Result { + 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, + Path(equipment_id): Path, +) -> Result { + 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, + Path(equipment_id): Path, +) -> Result { + 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, + pub description: Option, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateEquipmentReq { + #[validate(length(min = 1, max = 100))] + pub code: Option, + #[validate(length(min = 1, max = 100))] + pub name: Option, + pub kind: Option, + pub description: Option, +} + +pub async fn create_equipment( + State(state): State, + Json(payload): Json, +) -> Result { + 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, + Path(equipment_id): Path, + Json(payload): Json, +) -> Result { + 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, + Path(equipment_id): Path, +) -> Result { + 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) +} diff --git a/src/handler/point.rs b/src/handler/point.rs index a7ba175..77851e9 100644 --- a/src/handler/point.rs +++ b/src/handler/point.rs @@ -157,6 +157,12 @@ pub struct BatchSetPointTagsReq { pub tag_id: Option, } +#[derive(Deserialize, Validate)] +pub struct BatchSetPointEquipmentReq { + pub point_ids: Vec, + pub equipment_id: Option, +} + /// Update point metadata (name/description/unit only). pub async fn update_point( State(state): State, @@ -299,6 +305,58 @@ pub async fn batch_set_point_tags( }))) } +pub async fn batch_set_point_equipment( + State(state): State, + Json(payload): Json, +) -> Result { + 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 = 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::("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. pub async fn delete_point( State(state): State, diff --git a/src/main.rs b/src/main.rs index 40f5e01..344558a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,27 @@ -mod model; mod config; -mod util; +mod connection; mod db; +mod event; mod handler; mod middleware; -mod connection; -mod event; +mod model; mod service; -mod websocket; mod telemetry; -use config::AppConfig; -use tower_http::cors::{Any, CorsLayer}; -use tower_http::services::ServeDir; -use db::init_database; -use middleware::simple_logger; -use connection::ConnectionManager; -use event::EventManager; -use std::sync::Arc; +mod util; +mod websocket; use axum::{ routing::{get, put}, 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 tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; #[derive(Clone)] pub struct AppState { @@ -38,7 +37,9 @@ async fn main() { util::log::init_logger(); 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 ws_manager = Arc::new(websocket::WebSocketManager::new()); @@ -49,15 +50,13 @@ async fn main() { )); connection_manager.set_event_manager(event_manager.clone()); connection_manager.set_pool_and_start_reconnect_task(Arc::new(pool.clone())); - - let connection_manager = Arc::new(connection_manager); - + let connection_manager = Arc::new(connection_manager); // Connect to all enabled sources concurrently let sources = service::get_all_enabled_sources(&pool) .await - .expect("Failed to fetch sources"); + .expect("Failed to fetch sources"); // Spawn a task for each source to connect and subscribe concurrently let mut tasks = Vec::new(); @@ -94,7 +93,7 @@ async fn main() { let addr = format!("{}:{}", config.server_host, config.server_port); tracing::info!("Starting server at http://{}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - + let ui_url = format!("http://{}:{}/ui", "localhost", config.server_port); let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); let shutdown_tx_ctrl = shutdown_tx.clone(); @@ -108,14 +107,14 @@ async fn main() { .expect("Failed to install Ctrl+C handler"); let _ = shutdown_tx_ctrl.send(()).await; }); - - let shutdown_signal = async move{ + + let shutdown_signal = async move { let _ = shutdown_rx.recv().await; tracing::info!("Received shutdown signal, closing all connections..."); connection_manager_for_shutdown.disconnect_all().await; tracing::info!("All connections closed"); }; - + axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal) .await @@ -124,37 +123,104 @@ async fn main() { fn build_router(state: AppState) -> Router { let all_route = Router::new() - .route("/api/source", get(handler::source::get_source_list).post(handler::source::create_source)) - .route("/api/source/{source_id}", axum::routing::delete(handler::source::delete_source).put(handler::source::update_source)) - .route("/api/source/{source_id}/reconnect", axum::routing::post(handler::source::reconnect_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("/api/point", get(handler::point::get_point_list)) - .route( - "/api/point/value/batch", - 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/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)); + .route( + "/api/source", + get(handler::source::get_source_list).post(handler::source::create_source), + ) + .route( + "/api/source/{source_id}", + axum::routing::delete(handler::source::delete_source) + .put(handler::source::update_source), + ) + .route( + "/api/source/{source_id}/reconnect", + axum::routing::post(handler::source::reconnect_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("/api/point", get(handler::point::get_point_list)) + .route( + "/api/point/value/batch", + 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() .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/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( CorsLayer::new() @@ -241,7 +307,8 @@ mod tray { _event_loop: &ActiveEventLoop, _window_id: winit::window::WindowId, _event: winit::event::WindowEvent, - ) {} + ) { + } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { event_loop.set_control_flow(ControlFlow::Wait); diff --git a/src/service.rs b/src/service.rs index 6d3225b..33251cd 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,420 +1,9 @@ -use crate::model::{PointSubscriptionInfo, Source}; -use sqlx::{query_as, PgPool}; +mod equipment; +mod point; +mod source; +mod tag; -pub async fn get_enabled_source( - pool: &PgPool, - source_id: uuid::Uuid, -) -> Result, 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, 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, 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>, 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> = - 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, 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, -) -> Result { - 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, - page_size: i32, - offset: u32, -) -> Result, 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 { - 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, 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, 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, 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 { - 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 { - let result = sqlx::query(r#"DELETE FROM tag WHERE id = $1"#) - .bind(tag_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} +pub use equipment::*; +pub use point::*; +pub use source::*; +pub use tag::*; diff --git a/src/service/equipment.rs b/src/service/equipment.rs new file mode 100644 index 0000000..63b7c77 --- /dev/null +++ b/src/service/equipment.rs @@ -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, 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 { + 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, 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::("point_count"), + }) + .collect()) +} + +pub async fn get_equipment_by_id( + pool: &PgPool, + equipment_id: uuid::Uuid, +) -> Result, 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, 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 { + 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 { + let result = sqlx::query(r#"DELETE FROM equipment WHERE id = $1"#) + .bind(equipment_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} diff --git a/src/service/point.rs b/src/service/point.rs new file mode 100644 index 0000000..1818e7e --- /dev/null +++ b/src/service/point.rs @@ -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, 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>, 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> = 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, 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, +) -> Result { + 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, + page_size: i32, + offset: u32, +) -> Result, 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 + } + } + } +} diff --git a/src/service/source.rs b/src/service/source.rs new file mode 100644 index 0000000..5a8ef91 --- /dev/null +++ b/src/service/source.rs @@ -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, 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, sqlx::Error> { + query_as::<_, Source>("SELECT * FROM source WHERE enabled = true") + .fetch_all(pool) + .await +} diff --git a/src/service/tag.rs b/src/service/tag.rs new file mode 100644 index 0000000..aa08e67 --- /dev/null +++ b/src/service/tag.rs @@ -0,0 +1,184 @@ +use crate::model::{Point, Tag}; +use sqlx::{query_as, PgPool}; + +pub async fn get_tags_count(pool: &PgPool) -> Result { + 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, 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, 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, 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 { + 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 { + let result = sqlx::query(r#"DELETE FROM tag WHERE id = $1"#) + .bind(tag_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} diff --git a/web/api-md.js b/web/api-md.js deleted file mode 100644 index 44fa273..0000000 --- a/web/api-md.js +++ /dev/null @@ -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(); diff --git a/web/app.js b/web/app.js deleted file mode 100644 index 0aabde3..0000000 --- a/web/app.js +++ /dev/null @@ -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, '$1'); - html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); - html = html.replace(/\*([^*\n]+)\*/g, '$1'); - 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(`

${renderInlineMarkdown(paragraph.join(' '))}

`); - paragraph = []; - } - - function flushList() { - if (!listItems.length) return; - const tag = listType || 'ul'; - blocks.push(`<${tag}>${listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join('')}`); - listItems = []; - listType = null; - } - - function flushCodeFence() { - if (!codeFence) return; - blocks.push(`
    ${escapeHtml(codeLines.join('\n'))}
    `); - 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('
    '); - 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(`${renderInlineMarkdown(title)}`); - continue; - } - - const quote = trimmed.match(/^>\s?(.*)$/); - if (quote) { - flushParagraph(); - flushList(); - blocks.push(`
    ${renderInlineMarkdown(quote[1])}
    `); - 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 = '
    无目录
    '; - return; - } - - apiDocToc.innerHTML = items - .map((item) => `${escapeHtml(item.title)}`) - .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 = '

    加载中...

    '; - if (apiDocToc) { - apiDocToc.innerHTML = '
    加载中...
    '; - } - 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 = `

    加载 API.md 失败

    ${escapeHtml(err.message || 'unknown error')}
    `; - if (apiDocToc) { - apiDocToc.innerHTML = '
    目录加载失败
    '; - } - } - } -} - -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 = `${source.name}`; - 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 = '
    请选择数据源
    '; - 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 = '暂无 Points'; - 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 = `
    ${point.name}
    ${point.node_id}
    `; - - 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(); diff --git a/web/index.html b/web/index.html index b240a83..4b0eed6 100644 --- a/web/index.html +++ b/web/index.html @@ -4,12 +4,13 @@ PLC Control - +
    PLC Control
    +
    Ready
    @@ -37,11 +38,12 @@ - - + + - - + + + @@ -58,11 +60,11 @@
    -

    测点曲线

    +

    点位曲线

    -
    点击上方点位表中的某一行查看曲线
    +
    点击上方点位表中的一行查看曲线
    @@ -71,8 +73,8 @@ + + +
    名称名称 质量更新时间设备/角色更新时间
    + + + + + + + + +
    点位角色操作
    + + + + + + + - + diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..a6393d2 --- /dev/null +++ b/web/js/api.js @@ -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 || "请求失败"); + }); +} diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..85eedd4 --- /dev/null +++ b/web/js/app.js @@ -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(); diff --git a/web/js/chart.js b/web/js/chart.js new file mode 100644 index 0000000..049d65d --- /dev/null +++ b/web/js/chart.js @@ -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}`; +} diff --git a/web/js/docs.js b/web/js/docs.js new file mode 100644 index 0000000..c30741b --- /dev/null +++ b/web/js/docs.js @@ -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(`

    ${escapeHtml(paragraph.join(" "))}

    `); + paragraph = []; + }; + + const flushCode = () => { + if (!codeBuffer.length) { + return; + } + blocks.push(`
    ${escapeHtml(codeBuffer.join("\n"))}
    `); + 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(`${escapeHtml(textValue)}`); + 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 || "

    API.md 为空

    "; + dom.apiDocToc.innerHTML = headings.length + ? headings + .map( + (item) => + `${escapeHtml(item.text)}`, + ) + .join("") + : "
    未解析到标题
    "; + + 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"); +} diff --git a/web/js/dom.js b/web/js/dom.js new file mode 100644 index 0000000..5cf2ea4 --- /dev/null +++ b/web/js/dom.js @@ -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"), +}; diff --git a/web/js/equipment.js b/web/js/equipment.js new file mode 100644 index 0000000..f54003f --- /dev/null +++ b/web/js/equipment.js @@ -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 = ['']; + state.equipments.forEach((item) => { + const equipment = equipmentOf(item); + const isSelected = equipment.id === selected ? "selected" : ""; + options.push( + ``, + ); + }); + dom.bindingEquipmentId.innerHTML = options.join(""); +} + +export function resetEquipmentForm() { + state.selectedEquipmentId = null; + dom.equipmentForm.reset(); + dom.equipmentId.value = ""; + dom.equipmentPointList.innerHTML = + '请选择设备'; +} + +export function renderEquipments() { + dom.equipmentList.innerHTML = ""; + + if (!state.equipments.length) { + dom.equipmentList.innerHTML = '
    暂无设备
    '; + dom.equipmentPointList.innerHTML = + '暂无设备点位'; + 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 = ` +
    + ${equipment.code} + ${item.point_count ?? 0} pts +
    +
    ${equipment.name}
    +
    ${equipment.kind || "未设置类型"}
    + `; + + 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 = + '该设备下暂无点位'; + return; + } + + points.forEach((point) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + +
    ${point.name}
    +
    ${point.node_id}
    + + ${point.signal_role || "--"} + + `; + + const actionCell = tr.lastElementChild; + + const editBtn = document.createElement("button"); + editBtn.className = "secondary"; + editBtn.textContent = "编辑绑定"; + editBtn.addEventListener("click", () => openPointBinding(point)); + + const clearBtn = document.createElement("button"); + clearBtn.className = "secondary"; + clearBtn.textContent = "解绑"; + clearBtn.addEventListener("click", () => { + clearPointBinding(point.id).catch((error) => { + dom.statusText.textContent = error.message; + }); + }); + + actionCell.append(editBtn, clearBtn); + dom.equipmentPointList.appendChild(tr); + }); +} + +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(); +} diff --git a/web/js/logs.js b/web/js/logs.js new file mode 100644 index 0000000..f79a962 --- /dev/null +++ b/web/js/logs.js @@ -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); + }; +} diff --git a/web/js/points.js b/web/js/points.js new file mode 100644 index 0000000..2ba667c --- /dev/null +++ b/web/js/points.js @@ -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 = '
    请选择数据源
    '; + 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 = '暂无点位'; + 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 = ` + +
    ${point.name}
    +
    ${point.node_id}
    + + ${formatValue(monitor)} + ${(monitor?.quality || "unknown").toUpperCase()} + +
    +
    ${equipment ? equipment.name : '未绑定'}
    +
    ${point.signal_role || "--"}
    +
    + + ${monitor?.timestamp || "--"} + + `; + + 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(); +} diff --git a/web/js/sources.js b/web/js/sources.js new file mode 100644 index 0000000..0725130 --- /dev/null +++ b/web/js/sources.js @@ -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 = ` +
    + ${source.name} + ${source.is_connected ? "在线" : "离线"} +
    +
    ${source.endpoint}
    + `; + + 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(); +} diff --git a/web/js/state.js b/web/js/state.js new file mode 100644 index 0000000..7d57bf7 --- /dev/null +++ b/web/js/state.js @@ -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, +}; diff --git a/web/styles.css b/web/styles.css index 9b1544a..4fecdc2 100644 --- a/web/styles.css +++ b/web/styles.css @@ -373,7 +373,8 @@ button.danger:hover { background: var(--danger-hover); } .form input[type="url"], .form input[type="password"], .form input[type="number"], -.form input:not([type]) { +.form input:not([type]), +.form select { padding: 7px 10px; border-radius: var(--radius); border: 1px solid var(--border); @@ -383,7 +384,8 @@ button.danger:hover { background: var(--danger-hover); } transition: border-color 0.15s; } -.form input:focus { +.form input:focus, +.form select:focus { outline: none; border-color: var(--accent); 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); } +.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 { border-right: 1px solid var(--border-light); 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; } .drawer { width: 100vw; } .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-view { padding: 0; } .doc-card { border-left: none; border-right: none; }