Compare commits
2 Commits
6102ed712f
...
429c2d0b17
| Author | SHA1 | Date |
|---|---|---|
|
|
429c2d0b17 | |
|
|
368faf290a |
|
|
@ -138,6 +138,7 @@ dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"plc_platform_core",
|
"plc_platform_core",
|
||||||
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,25 @@ use crate::{
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
connection::ConnectionManager,
|
connection::ConnectionManager,
|
||||||
control,
|
control,
|
||||||
db::init_database,
|
|
||||||
event::EventManager,
|
event::EventManager,
|
||||||
router::build_router,
|
router::build_router,
|
||||||
websocket,
|
websocket::WebSocketManager,
|
||||||
};
|
};
|
||||||
|
use plc_platform_core::platform_context::PlatformContext;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
pub pool: sqlx::PgPool,
|
pub platform: PlatformContext,
|
||||||
pub connection_manager: Arc<ConnectionManager>,
|
|
||||||
pub event_manager: Arc<EventManager>,
|
pub event_manager: Arc<EventManager>,
|
||||||
pub ws_manager: Arc<websocket::WebSocketManager>,
|
|
||||||
pub control_runtime: Arc<control::runtime::ControlRuntimeStore>,
|
pub control_runtime: Arc<control::runtime::ControlRuntimeStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub async fn run() {
|
pub async fn run() {
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
plc_platform_core::util::log::init_logger();
|
plc_platform_core::util::log::init_logger();
|
||||||
let _platform = plc_platform_core::bootstrap::bootstrap_platform();
|
|
||||||
let _single_instance =
|
let _single_instance =
|
||||||
match plc_platform_core::util::single_instance::try_acquire("PLCControl.FeederDistributor") {
|
match plc_platform_core::util::single_instance::try_acquire("PLCControl.FeederDistributor") {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
|
|
@ -39,31 +37,31 @@ pub async fn run() {
|
||||||
};
|
};
|
||||||
|
|
||||||
let config = AppConfig::from_env().expect("Failed to load configuration");
|
let config = AppConfig::from_env().expect("Failed to load configuration");
|
||||||
let pool = init_database(&config.database_url)
|
let mut builder = plc_platform_core::bootstrap::bootstrap_platform(&config.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to initialize database");
|
.expect("Failed to bootstrap platform");
|
||||||
|
|
||||||
let mut connection_manager = ConnectionManager::new();
|
|
||||||
let ws_manager = Arc::new(websocket::WebSocketManager::new());
|
|
||||||
let event_manager = Arc::new(EventManager::new(
|
let event_manager = Arc::new(EventManager::new(
|
||||||
pool.clone(),
|
builder.pool.clone(),
|
||||||
Arc::new(connection_manager.clone()),
|
Arc::new(builder.connection_manager.clone()),
|
||||||
Some(ws_manager.clone()),
|
Some(builder.ws_manager.clone()),
|
||||||
));
|
));
|
||||||
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);
|
builder.connection_manager.set_event_manager(event_manager.clone());
|
||||||
|
builder.connection_manager.set_pool_and_start_reconnect_task(Arc::new(builder.pool.clone()));
|
||||||
|
|
||||||
|
let platform = builder.build();
|
||||||
|
|
||||||
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
|
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
|
||||||
|
|
||||||
let sources = crate::service::get_all_enabled_sources(&pool)
|
let sources = crate::service::get_all_enabled_sources(&platform.pool)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to fetch sources");
|
.expect("Failed to fetch sources");
|
||||||
|
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
for source in sources {
|
for source in sources {
|
||||||
let cm = connection_manager.clone();
|
let cm = platform.connection_manager.clone();
|
||||||
let p = pool.clone();
|
let p = platform.pool.clone();
|
||||||
let source_name = source.name.clone();
|
let source_name = source.name.clone();
|
||||||
let source_id = source.id;
|
let source_id = source.id;
|
||||||
|
|
||||||
|
|
@ -84,10 +82,8 @@ pub async fn run() {
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
pool,
|
platform,
|
||||||
connection_manager: connection_manager.clone(),
|
|
||||||
event_manager,
|
event_manager,
|
||||||
ws_manager,
|
|
||||||
control_runtime: control_runtime.clone(),
|
control_runtime: control_runtime.clone(),
|
||||||
};
|
};
|
||||||
control::engine::start(state.clone(), control_runtime);
|
control::engine::start(state.clone(), control_runtime);
|
||||||
|
|
@ -106,7 +102,7 @@ pub async fn run() {
|
||||||
let rt_handle = tokio::runtime::Handle::current();
|
let rt_handle = tokio::runtime::Handle::current();
|
||||||
init_tray(ui_url, shutdown_tx.clone(), rt_handle);
|
init_tray(ui_url, shutdown_tx.clone(), rt_handle);
|
||||||
|
|
||||||
let connection_manager_for_shutdown = connection_manager.clone();
|
let connection_manager_for_shutdown = state.platform.connection_manager.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::signal::ctrl_c()
|
tokio::signal::ctrl_c()
|
||||||
.await
|
.await
|
||||||
|
|
@ -133,12 +129,13 @@ pub fn test_state() -> AppState {
|
||||||
.connect_lazy(&database_url)
|
.connect_lazy(&database_url)
|
||||||
.expect("lazy pool should build");
|
.expect("lazy pool should build");
|
||||||
let connection_manager = Arc::new(ConnectionManager::new());
|
let connection_manager = Arc::new(ConnectionManager::new());
|
||||||
let ws_manager = Arc::new(websocket::WebSocketManager::new());
|
let ws_manager = Arc::new(WebSocketManager::new());
|
||||||
let event_manager = Arc::new(EventManager::new(
|
let event_manager = Arc::new(EventManager::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
connection_manager.clone(),
|
connection_manager.clone(),
|
||||||
Some(ws_manager.clone()),
|
Some(ws_manager.clone()),
|
||||||
));
|
));
|
||||||
|
let platform = PlatformContext::new(pool, connection_manager, ws_manager);
|
||||||
|
|
||||||
AppState {
|
AppState {
|
||||||
config: AppConfig {
|
config: AppConfig {
|
||||||
|
|
@ -148,10 +145,8 @@ pub fn test_state() -> AppState {
|
||||||
write_api_key: Some("test-write-key".to_string()),
|
write_api_key: Some("test-write-key".to_string()),
|
||||||
simulate_plc: false,
|
simulate_plc: false,
|
||||||
},
|
},
|
||||||
pool,
|
platform,
|
||||||
connection_manager,
|
|
||||||
event_manager,
|
event_manager,
|
||||||
ws_manager,
|
|
||||||
control_runtime: Arc::new(control::runtime::ControlRuntimeStore::new()),
|
control_runtime: Arc::new(control::runtime::ControlRuntimeStore::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ async fn supervise(state: AppState, store: Arc<ControlRuntimeStore>) {
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
match crate::service::get_all_enabled_units(&state.pool).await {
|
match crate::service::get_all_enabled_units(&state.platform.pool).await {
|
||||||
Ok(units) => {
|
Ok(units) => {
|
||||||
for unit in units {
|
for unit in units {
|
||||||
let needs_spawn = tasks
|
let needs_spawn = tasks
|
||||||
|
|
@ -63,7 +63,7 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Reload unit config on each iteration to detect disable/delete.
|
// Reload unit config on each iteration to detect disable/delete.
|
||||||
let unit = match crate::service::get_unit_by_id(&state.pool, unit_id).await {
|
let unit = match crate::service::get_unit_by_id(&state.platform.pool, unit_id).await {
|
||||||
Ok(Some(u)) if u.enabled => u,
|
Ok(Some(u)) if u.enabled => u,
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!("Engine: unit {} disabled or deleted, task exiting", unit_id);
|
tracing::info!("Engine: unit {} disabled or deleted, task exiting", unit_id);
|
||||||
|
|
@ -114,11 +114,11 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Send feeder start command.
|
// Send feeder start command.
|
||||||
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
|
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||||
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
||||||
drop(monitor);
|
drop(monitor);
|
||||||
if let Some((pid, vt)) = cmd {
|
if let Some((pid, vt)) = cmd {
|
||||||
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
|
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||||
tracing::warn!("Engine: start feeder failed for unit {}: {}", unit_id, e);
|
tracing::warn!("Engine: start feeder failed for unit {}: {}", unit_id, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -146,11 +146,11 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Stop feeder.
|
// Stop feeder.
|
||||||
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
|
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||||
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
||||||
drop(monitor);
|
drop(monitor);
|
||||||
if let Some((pid, vt)) = cmd {
|
if let Some((pid, vt)) = cmd {
|
||||||
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
|
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||||
tracing::warn!("Engine: stop feeder failed for unit {}: {}", unit_id, e);
|
tracing::warn!("Engine: stop feeder failed for unit {}: {}", unit_id, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -166,11 +166,11 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
||||||
|
|
||||||
if unit.acc_time_sec > 0 && runtime.accumulated_run_sec >= unit.acc_time_sec as i64 * 1000 {
|
if unit.acc_time_sec > 0 && runtime.accumulated_run_sec >= unit.acc_time_sec as i64 * 1000 {
|
||||||
// Accumulated threshold reached; start distributor.
|
// Accumulated threshold reached; start distributor.
|
||||||
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
|
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||||
let dist_cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
let dist_cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
||||||
drop(monitor);
|
drop(monitor);
|
||||||
if let Some((pid, vt)) = dist_cmd {
|
if let Some((pid, vt)) = dist_cmd {
|
||||||
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
|
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||||
tracing::warn!("Engine: start distributor failed for unit {}: {}", unit_id, e);
|
tracing::warn!("Engine: start distributor failed for unit {}: {}", unit_id, e);
|
||||||
} else if state.config.simulate_plc {
|
} else if state.config.simulate_plc {
|
||||||
if let Some(eq_id) = kind_eq_ids.get("distributor").copied() {
|
if let Some(eq_id) = kind_eq_ids.get("distributor").copied() {
|
||||||
|
|
@ -191,11 +191,11 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
||||||
if !wait_phase(&state, &store, &unit, &all_roles, ¬ify, &mut fault_tick).await {
|
if !wait_phase(&state, &store, &unit, &all_roles, ¬ify, &mut fault_tick).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
|
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||||
let cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
let cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
||||||
drop(monitor);
|
drop(monitor);
|
||||||
if let Some((pid, vt)) = cmd {
|
if let Some((pid, vt)) = cmd {
|
||||||
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
|
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||||
tracing::warn!("Engine: stop distributor failed for unit {}: {}", unit_id, e);
|
tracing::warn!("Engine: stop distributor failed for unit {}: {}", unit_id, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +269,7 @@ async fn wait_phase(
|
||||||
|
|
||||||
async fn push_ws(state: &AppState, runtime: &UnitRuntime) {
|
async fn push_ws(state: &AppState, runtime: &UnitRuntime) {
|
||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
.ws_manager
|
.platform.ws_manager
|
||||||
.send_to_public(WsMessage::UnitRuntimeChanged(runtime.clone()))
|
.send_to_public(WsMessage::UnitRuntimeChanged(runtime.clone()))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -286,7 +286,7 @@ async fn check_fault_comm(
|
||||||
all_roles: &[(Uuid, HashMap<String, EquipmentRolePoint>)],
|
all_roles: &[(Uuid, HashMap<String, EquipmentRolePoint>)],
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let monitor = state
|
let monitor = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -410,10 +410,10 @@ type EquipMaps = (
|
||||||
);
|
);
|
||||||
|
|
||||||
async fn load_equipment_maps(state: &AppState, unit_id: Uuid) -> Result<EquipMaps, sqlx::Error> {
|
async fn load_equipment_maps(state: &AppState, unit_id: Uuid) -> Result<EquipMaps, sqlx::Error> {
|
||||||
let equipment_list = crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?;
|
let equipment_list = crate::service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||||
let equipment_ids: Vec<Uuid> = equipment_list.iter().map(|equip| equip.id).collect();
|
let equipment_ids: Vec<Uuid> = equipment_list.iter().map(|equip| equip.id).collect();
|
||||||
let role_point_rows =
|
let role_point_rows =
|
||||||
crate::service::get_signal_role_points_batch(&state.pool, &equipment_ids).await?;
|
crate::service::get_signal_role_points_batch(&state.platform.pool, &equipment_ids).await?;
|
||||||
let mut role_points_by_equipment: HashMap<Uuid, Vec<EquipmentRolePoint>> = HashMap::new();
|
let mut role_points_by_equipment: HashMap<Uuid, Vec<EquipmentRolePoint>> = HashMap::new();
|
||||||
for row in role_point_rows {
|
for row in role_point_rows {
|
||||||
role_points_by_equipment
|
role_points_by_equipment
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ async fn run(state: AppState) {
|
||||||
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
|
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
|
||||||
|
|
||||||
// Pick a random enabled unit.
|
// Pick a random enabled unit.
|
||||||
let units = match crate::service::get_all_enabled_units(&state.pool).await {
|
let units = match crate::service::get_all_enabled_units(&state.platform.pool).await {
|
||||||
Ok(u) if !u.is_empty() => u,
|
Ok(u) if !u.is_empty() => u,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
@ -39,7 +39,7 @@ async fn run(state: AppState) {
|
||||||
|
|
||||||
// Pick a random equipment in that unit.
|
// Pick a random equipment in that unit.
|
||||||
let equipments =
|
let equipments =
|
||||||
match crate::service::get_equipment_by_unit_id(&state.pool, unit.id).await {
|
match crate::service::get_equipment_by_unit_id(&state.platform.pool, unit.id).await {
|
||||||
Ok(e) if !e.is_empty() => e,
|
Ok(e) if !e.is_empty() => e,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
@ -47,7 +47,7 @@ async fn run(state: AppState) {
|
||||||
|
|
||||||
// Find which of rem / flt this equipment has.
|
// Find which of rem / flt this equipment has.
|
||||||
let role_points =
|
let role_points =
|
||||||
match crate::service::get_equipment_role_points(&state.pool, eq.id).await {
|
match crate::service::get_equipment_role_points(&state.platform.pool, eq.id).await {
|
||||||
Ok(rp) if !rp.is_empty() => rp,
|
Ok(rp) if !rp.is_empty() => rp,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
@ -105,7 +105,7 @@ async fn run(state: AppState) {
|
||||||
/// Called by the engine and control handler when SIMULATE_PLC=true.
|
/// Called by the engine and control handler when SIMULATE_PLC=true.
|
||||||
pub async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, run_on: bool) {
|
pub async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, run_on: bool) {
|
||||||
let role_points =
|
let role_points =
|
||||||
match crate::service::get_equipment_role_points(&state.pool, equipment_id).await {
|
match crate::service::get_equipment_role_points(&state.platform.pool, equipment_id).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("simulate_run_feedback: db error: {}", e);
|
tracing::warn!("simulate_run_feedback: db error: {}", e);
|
||||||
|
|
@ -123,7 +123,7 @@ pub async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, run_on:
|
||||||
pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
|
pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
|
||||||
let write_json = serde_json::json!(if value_on { 1 } else { 0 });
|
let write_json = serde_json::json!(if value_on { 1 } else { 0 });
|
||||||
let write_ok = match state
|
let write_ok = match state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.write_point_values_batch(BatchSetPointValueReq {
|
.write_point_values_batch(BatchSetPointValueReq {
|
||||||
items: vec![SetPointValueReqItem {
|
items: vec![SetPointValueReqItem {
|
||||||
point_id,
|
point_id,
|
||||||
|
|
@ -143,7 +143,7 @@ pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
|
||||||
// Fallback: patch the monitor cache directly and broadcast over WS.
|
// Fallback: patch the monitor cache directly and broadcast over WS.
|
||||||
let (value, value_type, value_text) = {
|
let (value, value_type, value_text) = {
|
||||||
let guard = state
|
let guard = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
match guard.get(&point_id).and_then(|m| m.value_type.as_ref()) {
|
match guard.get(&point_id).and_then(|m| m.value_type.as_ref()) {
|
||||||
|
|
@ -182,7 +182,7 @@ pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.update_point_monitor_data(monitor.clone())
|
.update_point_monitor_data(monitor.clone())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -191,7 +191,7 @@ pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = state
|
let _ = state
|
||||||
.ws_manager
|
.platform.ws_manager
|
||||||
.send_to_public(WsMessage::PointNewValue(monitor))
|
.send_to_public(WsMessage::PointNewValue(monitor))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,11 @@ pub async fn validate_manual_control(
|
||||||
equipment_id: Uuid,
|
equipment_id: Uuid,
|
||||||
action: ControlAction,
|
action: ControlAction,
|
||||||
) -> Result<ManualControlContext, ApiErr> {
|
) -> Result<ManualControlContext, ApiErr> {
|
||||||
let equipment = crate::service::get_equipment_by_id(&state.pool, equipment_id)
|
let equipment = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Equipment not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Equipment not found".to_string(), None))?;
|
||||||
|
|
||||||
let role_points = crate::service::get_equipment_role_points(&state.pool, equipment_id).await?;
|
let role_points = crate::service::get_equipment_role_points(&state.platform.pool, equipment_id).await?;
|
||||||
if role_points.is_empty() {
|
if role_points.is_empty() {
|
||||||
return Err(ApiErr::BadRequest(
|
return Err(ApiErr::BadRequest(
|
||||||
"Equipment has no bound role points".to_string(),
|
"Equipment has no bound role points".to_string(),
|
||||||
|
|
@ -75,7 +75,7 @@ pub async fn validate_manual_control(
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
let monitor_guard = state
|
let monitor_guard = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -148,7 +148,7 @@ pub async fn validate_manual_control(
|
||||||
}
|
}
|
||||||
|
|
||||||
let command_value_type = state
|
let command_value_type = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await
|
.await
|
||||||
.get(&command_point.point_id)
|
.get(&command_point.point_id)
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,9 @@ pub async fn get_unit_list(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
query.validate()?;
|
query.validate()?;
|
||||||
|
|
||||||
let total = crate::service::get_units_count(&state.pool, query.keyword.as_deref()).await?;
|
let total = crate::service::get_units_count(&state.platform.pool, query.keyword.as_deref()).await?;
|
||||||
let units = crate::service::get_units_paginated(
|
let units = crate::service::get_units_paginated(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
query.keyword.as_deref(),
|
query.keyword.as_deref(),
|
||||||
query.pagination.page_size,
|
query.pagination.page_size,
|
||||||
query.pagination.offset(),
|
query.pagination.offset(),
|
||||||
|
|
@ -81,14 +81,14 @@ pub async fn get_unit_list(
|
||||||
|
|
||||||
let unit_ids: Vec<Uuid> = units.iter().map(|u| u.id).collect();
|
let unit_ids: Vec<Uuid> = units.iter().map(|u| u.id).collect();
|
||||||
let all_equipments =
|
let all_equipments =
|
||||||
crate::service::get_equipment_by_unit_ids(&state.pool, &unit_ids).await?;
|
crate::service::get_equipment_by_unit_ids(&state.platform.pool, &unit_ids).await?;
|
||||||
|
|
||||||
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
|
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
|
||||||
let role_point_rows =
|
let role_point_rows =
|
||||||
crate::service::get_signal_role_points_batch(&state.pool, &eq_ids).await?;
|
crate::service::get_signal_role_points_batch(&state.platform.pool, &eq_ids).await?;
|
||||||
|
|
||||||
let monitor_guard = state
|
let monitor_guard = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -161,7 +161,7 @@ async fn send_equipment_command(
|
||||||
let pulse_ms = 300u64;
|
let pulse_ms = 300u64;
|
||||||
|
|
||||||
crate::control::command::send_pulse_command(
|
crate::control::command::send_pulse_command(
|
||||||
&state.connection_manager,
|
&state.platform.connection_manager,
|
||||||
context.command_point.point_id,
|
context.command_point.point_id,
|
||||||
context.command_value_type.as_ref(),
|
context.command_value_type.as_ref(),
|
||||||
pulse_ms,
|
pulse_ms,
|
||||||
|
|
@ -206,18 +206,18 @@ pub async fn get_unit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let unit = crate::service::get_unit_by_id(&state.pool, unit_id)
|
let unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||||
let runtime = state.control_runtime.get(unit_id).await;
|
let runtime = state.control_runtime.get(unit_id).await;
|
||||||
|
|
||||||
let all_equipments =
|
let all_equipments =
|
||||||
crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?;
|
crate::service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||||
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
|
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
|
||||||
let role_point_rows =
|
let role_point_rows =
|
||||||
crate::service::get_signal_role_points_batch(&state.pool, &eq_ids).await?;
|
crate::service::get_signal_role_points_batch(&state.platform.pool, &eq_ids).await?;
|
||||||
let monitor_guard = state
|
let monitor_guard = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
let mut role_points_map: std::collections::HashMap<
|
let mut role_points_map: std::collections::HashMap<
|
||||||
|
|
@ -273,18 +273,18 @@ pub async fn get_unit_detail(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let unit = crate::service::get_unit_by_id(&state.pool, unit_id)
|
let unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||||
|
|
||||||
let runtime = state.control_runtime.get(unit_id).await;
|
let runtime = state.control_runtime.get(unit_id).await;
|
||||||
|
|
||||||
let equipments = crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?;
|
let equipments = crate::service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||||
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
|
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
|
||||||
let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?;
|
let all_points = crate::service::get_points_by_equipment_ids(&state.platform.pool, &equipment_ids).await?;
|
||||||
|
|
||||||
let monitor_guard = state
|
let monitor_guard = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -358,7 +358,7 @@ pub async fn create_unit(
|
||||||
|
|
||||||
validate_unit_timing_order(run_time_sec, acc_time_sec)?;
|
validate_unit_timing_order(run_time_sec, acc_time_sec)?;
|
||||||
|
|
||||||
if crate::service::get_unit_by_code(&state.pool, &payload.code)
|
if crate::service::get_unit_by_code(&state.platform.pool, &payload.code)
|
||||||
.await?
|
.await?
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
|
|
@ -369,7 +369,7 @@ pub async fn create_unit(
|
||||||
}
|
}
|
||||||
|
|
||||||
let unit_id = crate::service::create_unit(
|
let unit_id = crate::service::create_unit(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
crate::service::CreateUnitParams {
|
crate::service::CreateUnitParams {
|
||||||
code: &payload.code,
|
code: &payload.code,
|
||||||
name: &payload.name,
|
name: &payload.name,
|
||||||
|
|
@ -421,7 +421,7 @@ pub async fn update_unit(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
payload.validate()?;
|
payload.validate()?;
|
||||||
|
|
||||||
let existing_unit = crate::service::get_unit_by_id(&state.pool, unit_id)
|
let existing_unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||||
|
|
||||||
|
|
@ -431,7 +431,7 @@ pub async fn update_unit(
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if let Some(code) = payload.code.as_deref() {
|
if let Some(code) = payload.code.as_deref() {
|
||||||
let duplicate = crate::service::get_unit_by_code(&state.pool, code).await?;
|
let duplicate = crate::service::get_unit_by_code(&state.platform.pool, code).await?;
|
||||||
if duplicate.as_ref().is_some_and(|item| item.id != unit_id) {
|
if duplicate.as_ref().is_some_and(|item| item.id != unit_id) {
|
||||||
return Err(ApiErr::BadRequest(
|
return Err(ApiErr::BadRequest(
|
||||||
"Unit code already exists".to_string(),
|
"Unit code already exists".to_string(),
|
||||||
|
|
@ -454,7 +454,7 @@ pub async fn update_unit(
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::service::update_unit(
|
crate::service::update_unit(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
unit_id,
|
unit_id,
|
||||||
crate::service::UpdateUnitParams {
|
crate::service::UpdateUnitParams {
|
||||||
code: payload.code.as_deref(),
|
code: payload.code.as_deref(),
|
||||||
|
|
@ -479,7 +479,7 @@ pub async fn delete_unit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let deleted = crate::service::delete_unit(&state.pool, unit_id).await?;
|
let deleted = crate::service::delete_unit(&state.platform.pool, unit_id).await?;
|
||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||||
}
|
}
|
||||||
|
|
@ -503,13 +503,13 @@ pub async fn get_event_list(
|
||||||
query.validate()?;
|
query.validate()?;
|
||||||
|
|
||||||
let total = crate::service::get_events_count(
|
let total = crate::service::get_events_count(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
query.unit_id,
|
query.unit_id,
|
||||||
query.event_type.as_deref(),
|
query.event_type.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let data = crate::service::get_events_paginated(
|
let data = crate::service::get_events_paginated(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
query.unit_id,
|
query.unit_id,
|
||||||
query.event_type.as_deref(),
|
query.event_type.as_deref(),
|
||||||
query.pagination.page_size,
|
query.pagination.page_size,
|
||||||
|
|
@ -529,7 +529,7 @@ pub async fn start_auto_unit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let unit = crate::service::get_unit_by_id(&state.pool, unit_id)
|
let unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||||
|
|
||||||
|
|
@ -564,7 +564,7 @@ pub async fn stop_auto_unit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
crate::service::get_unit_by_id(&state.pool, unit_id)
|
crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||||
|
|
||||||
|
|
@ -581,7 +581,7 @@ pub async fn stop_auto_unit(
|
||||||
pub async fn batch_start_auto(
|
pub async fn batch_start_auto(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let units = crate::service::get_all_enabled_units(&state.pool).await?;
|
let units = crate::service::get_all_enabled_units(&state.platform.pool).await?;
|
||||||
let mut started = Vec::new();
|
let mut started = Vec::new();
|
||||||
let mut skipped = Vec::new();
|
let mut skipped = Vec::new();
|
||||||
|
|
||||||
|
|
@ -611,7 +611,7 @@ pub async fn batch_start_auto(
|
||||||
pub async fn batch_stop_auto(
|
pub async fn batch_stop_auto(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let units = crate::service::get_all_enabled_units(&state.pool).await?;
|
let units = crate::service::get_all_enabled_units(&state.platform.pool).await?;
|
||||||
let mut stopped = Vec::new();
|
let mut stopped = Vec::new();
|
||||||
|
|
||||||
for unit in units {
|
for unit in units {
|
||||||
|
|
@ -635,7 +635,7 @@ pub async fn ack_fault_unit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
crate::service::get_unit_by_id(&state.pool, unit_id)
|
crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||||
|
|
||||||
|
|
@ -669,7 +669,7 @@ pub async fn get_unit_runtime(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
crate::service::get_unit_by_id(&state.pool, unit_id)
|
crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,9 @@ pub async fn get_equipment_list(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
query.validate()?;
|
query.validate()?;
|
||||||
|
|
||||||
let total = crate::service::get_equipment_count(&state.pool, query.keyword.as_deref()).await?;
|
let total = crate::service::get_equipment_count(&state.platform.pool, query.keyword.as_deref()).await?;
|
||||||
let items = crate::service::get_equipment_paginated(
|
let items = crate::service::get_equipment_paginated(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
query.keyword.as_deref(),
|
query.keyword.as_deref(),
|
||||||
query.pagination.page_size,
|
query.pagination.page_size,
|
||||||
query.pagination.offset(),
|
query.pagination.offset(),
|
||||||
|
|
@ -66,10 +66,10 @@ pub async fn get_equipment_list(
|
||||||
|
|
||||||
let equipment_ids: Vec<uuid::Uuid> = items.iter().map(|item| item.equipment.id).collect();
|
let equipment_ids: Vec<uuid::Uuid> = items.iter().map(|item| item.equipment.id).collect();
|
||||||
let role_point_rows =
|
let role_point_rows =
|
||||||
crate::service::get_signal_role_points_batch(&state.pool, &equipment_ids).await?;
|
crate::service::get_signal_role_points_batch(&state.platform.pool, &equipment_ids).await?;
|
||||||
|
|
||||||
let monitor_guard = state
|
let monitor_guard = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ pub async fn get_equipment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(equipment_id): Path<Uuid>,
|
Path(equipment_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let equipment = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
|
let equipment = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id).await?;
|
||||||
|
|
||||||
match equipment {
|
match equipment {
|
||||||
Some(item) => Ok(Json(item)),
|
Some(item) => Ok(Json(item)),
|
||||||
|
|
@ -122,12 +122,12 @@ pub async fn get_equipment_points(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(equipment_id): Path<Uuid>,
|
Path(equipment_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let exists = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
|
let exists = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id).await?;
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
return Err(ApiErr::NotFound("Equipment not found".to_string(), 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?;
|
let points = crate::service::get_points_by_equipment_id(&state.platform.pool, equipment_id).await?;
|
||||||
Ok(Json(points))
|
Ok(Json(points))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ pub async fn create_equipment(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
payload.validate()?;
|
payload.validate()?;
|
||||||
|
|
||||||
let exists = crate::service::get_equipment_by_code(&state.pool, &payload.code).await?;
|
let exists = crate::service::get_equipment_by_code(&state.platform.pool, &payload.code).await?;
|
||||||
if exists.is_some() {
|
if exists.is_some() {
|
||||||
return Err(ApiErr::BadRequest(
|
return Err(ApiErr::BadRequest(
|
||||||
"Equipment code already exists".to_string(),
|
"Equipment code already exists".to_string(),
|
||||||
|
|
@ -174,14 +174,14 @@ pub async fn create_equipment(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(unit_id) = payload.unit_id {
|
if let Some(unit_id) = payload.unit_id {
|
||||||
let unit_exists = crate::service::get_unit_by_id(&state.pool, unit_id).await?;
|
let unit_exists = crate::service::get_unit_by_id(&state.platform.pool, unit_id).await?;
|
||||||
if unit_exists.is_none() {
|
if unit_exists.is_none() {
|
||||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let equipment_id = crate::service::create_equipment(
|
let equipment_id = crate::service::create_equipment(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
payload.unit_id,
|
payload.unit_id,
|
||||||
&payload.code,
|
&payload.code,
|
||||||
&payload.name,
|
&payload.name,
|
||||||
|
|
@ -219,7 +219,7 @@ pub async fn update_equipment(
|
||||||
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
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?;
|
let exists = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id).await?;
|
||||||
let existing_equipment = if let Some(equipment) = exists {
|
let existing_equipment = if let Some(equipment) = exists {
|
||||||
equipment
|
equipment
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -227,14 +227,14 @@ pub async fn update_equipment(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(Some(unit_id)) = payload.unit_id {
|
if let Some(Some(unit_id)) = payload.unit_id {
|
||||||
let unit_exists = crate::service::get_unit_by_id(&state.pool, unit_id).await?;
|
let unit_exists = crate::service::get_unit_by_id(&state.platform.pool, unit_id).await?;
|
||||||
if unit_exists.is_none() {
|
if unit_exists.is_none() {
|
||||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(code) = payload.code.as_deref() {
|
if let Some(code) = payload.code.as_deref() {
|
||||||
let duplicate = crate::service::get_equipment_by_code(&state.pool, code).await?;
|
let duplicate = crate::service::get_equipment_by_code(&state.platform.pool, code).await?;
|
||||||
if duplicate
|
if duplicate
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|item| item.id != equipment_id)
|
.is_some_and(|item| item.id != equipment_id)
|
||||||
|
|
@ -247,7 +247,7 @@ pub async fn update_equipment(
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::service::update_equipment(
|
crate::service::update_equipment(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
equipment_id,
|
equipment_id,
|
||||||
payload.unit_id,
|
payload.unit_id,
|
||||||
payload.code.as_deref(),
|
payload.code.as_deref(),
|
||||||
|
|
@ -289,17 +289,17 @@ pub async fn batch_set_equipment_unit(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(unit_id) = payload.unit_id {
|
if let Some(unit_id) = payload.unit_id {
|
||||||
let unit_exists = crate::service::get_unit_by_id(&state.pool, unit_id).await?;
|
let unit_exists = crate::service::get_unit_by_id(&state.platform.pool, unit_id).await?;
|
||||||
if unit_exists.is_none() {
|
if unit_exists.is_none() {
|
||||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let before_unit_ids =
|
let before_unit_ids =
|
||||||
crate::service::get_unit_ids_by_equipment_ids(&state.pool, &payload.equipment_ids).await?;
|
crate::service::get_unit_ids_by_equipment_ids(&state.platform.pool, &payload.equipment_ids).await?;
|
||||||
|
|
||||||
let updated_count = crate::service::batch_set_equipment_unit(
|
let updated_count = crate::service::batch_set_equipment_unit(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
&payload.equipment_ids,
|
&payload.equipment_ids,
|
||||||
payload.unit_id,
|
payload.unit_id,
|
||||||
)
|
)
|
||||||
|
|
@ -321,8 +321,8 @@ pub async fn delete_equipment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(equipment_id): Path<Uuid>,
|
Path(equipment_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let unit_ids = crate::service::get_unit_ids_by_equipment_ids(&state.pool, &[equipment_id]).await?;
|
let unit_ids = crate::service::get_unit_ids_by_equipment_ids(&state.platform.pool, &[equipment_id]).await?;
|
||||||
let deleted = crate::service::delete_equipment(&state.pool, equipment_id).await?;
|
let deleted = crate::service::delete_equipment(&state.platform.pool, equipment_id).await?;
|
||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ pub async fn get_page_list(
|
||||||
Query(query): Query<GetPageListQuery>,
|
Query(query): Query<GetPageListQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
query.validate()?;
|
query.validate()?;
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
let pages: Vec<Page> = if let Some(name) = query.name {
|
let pages: Vec<Page> = if let Some(name) = query.name {
|
||||||
sqlx::query_as::<_, Page>(
|
sqlx::query_as::<_, Page>(
|
||||||
|
|
@ -50,7 +50,7 @@ pub async fn get_page(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let page = sqlx::query_as::<_, Page>("SELECT * FROM page WHERE id = $1")
|
let page = sqlx::query_as::<_, Page>("SELECT * FROM page WHERE id = $1")
|
||||||
.bind(page_id)
|
.bind(page_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.platform.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
match page {
|
match page {
|
||||||
|
|
@ -88,7 +88,7 @@ pub async fn create_page(
|
||||||
)
|
)
|
||||||
.bind(&payload.name)
|
.bind(&payload.name)
|
||||||
.bind(SqlxJson(payload.data))
|
.bind(SqlxJson(payload.data))
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.platform.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(serde_json::json!({
|
Ok((StatusCode::CREATED, Json(serde_json::json!({
|
||||||
|
|
@ -106,7 +106,7 @@ pub async fn update_page(
|
||||||
|
|
||||||
let exists = sqlx::query("SELECT 1 FROM page WHERE id = $1")
|
let exists = sqlx::query("SELECT 1 FROM page WHERE id = $1")
|
||||||
.bind(page_id)
|
.bind(page_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.platform.pool)
|
||||||
.await?;
|
.await?;
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
return Err(ApiErr::NotFound("Page not found".to_string(), None));
|
return Err(ApiErr::NotFound("Page not found".to_string(), None));
|
||||||
|
|
@ -145,7 +145,7 @@ pub async fn update_page(
|
||||||
}
|
}
|
||||||
query = query.bind(page_id);
|
query = query.bind(page_id);
|
||||||
|
|
||||||
query.execute(&state.pool).await?;
|
query.execute(&state.platform.pool).await?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"ok_msg": "Page updated successfully"
|
"ok_msg": "Page updated successfully"
|
||||||
|
|
@ -158,7 +158,7 @@ pub async fn delete_page(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let result = sqlx::query("DELETE FROM page WHERE id = $1")
|
let result = sqlx::query("DELETE FROM page WHERE id = $1")
|
||||||
.bind(page_id)
|
.bind(page_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.platform.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ pub async fn get_point_list(
|
||||||
Query(query): Query<GetPointListQuery>,
|
Query(query): Query<GetPointListQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
query.validate()?;
|
query.validate()?;
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
// Count total rows.
|
// Count total rows.
|
||||||
let total = crate::service::get_points_count(pool, query.source_id, query.equipment_id).await?;
|
let total = crate::service::get_points_count(pool, query.source_id, query.equipment_id).await?;
|
||||||
|
|
@ -85,7 +85,7 @@ pub async fn get_point_list(
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let monitor_guard = state
|
let monitor_guard = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_monitor_data_read_guard()
|
.get_point_monitor_data_read_guard()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -114,7 +114,7 @@ pub async fn get_point(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(point_id): Path<Uuid>,
|
Path(point_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
let point = crate::service::get_point_by_id(pool, point_id).await?;
|
let point = crate::service::get_point_by_id(pool, point_id).await?;
|
||||||
|
|
||||||
Ok(Json(point))
|
Ok(Json(point))
|
||||||
|
|
@ -125,7 +125,7 @@ pub async fn get_point_history(
|
||||||
Path(point_id): Path<Uuid>,
|
Path(point_id): Path<Uuid>,
|
||||||
Query(query): Query<GetPointHistoryQuery>,
|
Query(query): Query<GetPointHistoryQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
let point = crate::service::get_point_by_id(pool, point_id).await?;
|
let point = crate::service::get_point_by_id(pool, point_id).await?;
|
||||||
if point.is_none() {
|
if point.is_none() {
|
||||||
return Err(ApiErr::NotFound("Point not found".to_string(), None));
|
return Err(ApiErr::NotFound("Point not found".to_string(), None));
|
||||||
|
|
@ -133,7 +133,7 @@ pub async fn get_point_history(
|
||||||
|
|
||||||
let limit = query.limit.unwrap_or(120).clamp(1, 1000);
|
let limit = query.limit.unwrap_or(120).clamp(1, 1000);
|
||||||
let history = state
|
let history = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.get_point_history(point_id, limit)
|
.get_point_history(point_id, limit)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -194,7 +194,7 @@ pub async fn update_point(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
payload.validate()?;
|
payload.validate()?;
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
if payload.name.is_none()
|
if payload.name.is_none()
|
||||||
&& payload.description.is_none()
|
&& payload.description.is_none()
|
||||||
|
|
@ -317,7 +317,7 @@ pub async fn batch_set_point_tags(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
// If tag_id is provided, ensure tag exists.
|
// If tag_id is provided, ensure tag exists.
|
||||||
if let Some(tag_id) = payload.tag_id {
|
if let Some(tag_id) = payload.tag_id {
|
||||||
|
|
@ -372,7 +372,7 @@ pub async fn batch_set_point_equipment(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
if let Some(equipment_id) = payload.equipment_id {
|
if let Some(equipment_id) = payload.equipment_id {
|
||||||
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
|
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
|
||||||
|
|
@ -429,7 +429,7 @@ pub async fn delete_point(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(point_id): Path<Uuid>,
|
Path(point_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
let affected_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
|
let affected_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
|
||||||
|
|
||||||
let source_id = {
|
let source_id = {
|
||||||
|
|
@ -494,7 +494,7 @@ pub async fn batch_create_points(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
payload.validate()?;
|
payload.validate()?;
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
if payload.node_ids.is_empty() {
|
if payload.node_ids.is_empty() {
|
||||||
return Err(ApiErr::BadRequest(
|
return Err(ApiErr::BadRequest(
|
||||||
|
|
@ -614,7 +614,7 @@ pub async fn batch_delete_points(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
let point_ids = payload.point_ids;
|
let point_ids = payload.point_ids;
|
||||||
|
|
||||||
let grouped = crate::service::get_points_grouped_by_source(pool, &point_ids).await?;
|
let grouped = crate::service::get_points_grouped_by_source(pool, &point_ids).await?;
|
||||||
|
|
@ -673,7 +673,7 @@ pub async fn batch_set_point_value(
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = state
|
let result = state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.write_point_values_batch(payload)
|
.write_point_values_batch(payload)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,12 @@ impl From<Source> for SourcePublic {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_source_list(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
pub async fn get_source_list(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
let sources: Vec<Source> = crate::service::get_all_enabled_sources(pool).await?;
|
let sources: Vec<Source> = crate::service::get_all_enabled_sources(pool).await?;
|
||||||
|
|
||||||
// 鑾峰彇鎵€鏈夎繛鎺ョ姸鎬?
|
// 鑾峰彇鎵€鏈夎繛鎺ョ姸鎬?
|
||||||
let status_map: std::collections::HashMap<Uuid, (bool, Option<String>, Option<DateTime<Utc>>)> =
|
let status_map: std::collections::HashMap<Uuid, (bool, Option<String>, Option<DateTime<Utc>>)> =
|
||||||
state.connection_manager.get_all_status().await
|
state.platform.connection_manager.get_all_status().await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(source_id, s)| (source_id, (s.is_connected, s.last_error, Some(s.last_time))))
|
.map(|(source_id, s)| (source_id, (s.is_connected, s.last_error, Some(s.last_time))))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -132,7 +132,7 @@ pub async fn get_node_tree(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(source_id): Path<Uuid>,
|
Path(source_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
// 鏌ヨ鎵€鏈夊睘浜庤source鐨勮妭鐐?
|
// 鏌ヨ鎵€鏈夊睘浜庤source鐨勮妭鐐?
|
||||||
let nodes: Vec<Node> = sqlx::query_as::<_, Node>(
|
let nodes: Vec<Node> = sqlx::query_as::<_, Node>(
|
||||||
|
|
@ -212,7 +212,7 @@ pub async fn create_source(
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
payload.validate()?;
|
payload.validate()?;
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
let new_id = Uuid::new_v4();
|
let new_id = Uuid::new_v4();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -261,7 +261,7 @@ pub async fn update_source(
|
||||||
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
||||||
.bind(source_id)
|
.bind(source_id)
|
||||||
|
|
@ -311,7 +311,7 @@ pub async fn delete_source(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(source_id): Path<Uuid>,
|
Path(source_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
let source_name = sqlx::query_scalar::<_, String>("SELECT name FROM source WHERE id = $1")
|
let source_name = sqlx::query_scalar::<_, String>("SELECT name FROM source WHERE id = $1")
|
||||||
.bind(source_id)
|
.bind(source_id)
|
||||||
|
|
@ -334,7 +334,7 @@ pub async fn reconnect_source(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(source_id): Path<Uuid>,
|
Path(source_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
||||||
.bind(source_id)
|
.bind(source_id)
|
||||||
|
|
@ -349,7 +349,7 @@ pub async fn reconnect_source(
|
||||||
}
|
}
|
||||||
|
|
||||||
state
|
state
|
||||||
.connection_manager
|
.platform.connection_manager
|
||||||
.reconnect(pool, source_id)
|
.reconnect(pool, source_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||||
|
|
@ -362,7 +362,7 @@ pub async fn browse_and_save_nodes(
|
||||||
Path(source_id): Path<Uuid>,
|
Path(source_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
// 纭 source 瀛樺湪
|
// 纭 source 瀛樺湪
|
||||||
sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
||||||
|
|
@ -370,7 +370,7 @@ pub async fn browse_and_save_nodes(
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let session = state.connection_manager
|
let session = state.platform.connection_manager
|
||||||
.get_session(source_id)
|
.get_session(source_id)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| anyhow::anyhow!("Source not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("Source not connected"))?;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ pub async fn get_tag_list(
|
||||||
Query(query): Query<GetTagListQuery>,
|
Query(query): Query<GetTagListQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
query.validate()?;
|
query.validate()?;
|
||||||
let pool = &state.pool;
|
let pool = &state.platform.pool;
|
||||||
|
|
||||||
// Count total rows.
|
// Count total rows.
|
||||||
let total = crate::service::get_tags_count(pool).await?;
|
let total = crate::service::get_tags_count(pool).await?;
|
||||||
|
|
@ -43,7 +43,7 @@ pub async fn get_tag_points(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(tag_id): Path<Uuid>,
|
Path(tag_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let points = crate::service::get_tag_points(&state.pool, tag_id).await?;
|
let points = crate::service::get_tag_points(&state.platform.pool, tag_id).await?;
|
||||||
Ok(Json(points))
|
Ok(Json(points))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ pub async fn create_tag(
|
||||||
|
|
||||||
let point_ids = payload.point_ids.as_deref().unwrap_or(&[]);
|
let point_ids = payload.point_ids.as_deref().unwrap_or(&[]);
|
||||||
let tag_id = crate::service::create_tag(
|
let tag_id = crate::service::create_tag(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
&payload.name,
|
&payload.name,
|
||||||
payload.description.as_deref(),
|
payload.description.as_deref(),
|
||||||
point_ids,
|
point_ids,
|
||||||
|
|
@ -93,13 +93,13 @@ pub async fn update_tag(
|
||||||
payload.validate()?;
|
payload.validate()?;
|
||||||
|
|
||||||
// Ensure the target tag exists.
|
// Ensure the target tag exists.
|
||||||
let exists = crate::service::get_tag_by_id(&state.pool, tag_id).await?;
|
let exists = crate::service::get_tag_by_id(&state.platform.pool, tag_id).await?;
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
return Err(ApiErr::NotFound("Tag not found".to_string(), None));
|
return Err(ApiErr::NotFound("Tag not found".to_string(), None));
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::service::update_tag(
|
crate::service::update_tag(
|
||||||
&state.pool,
|
&state.platform.pool,
|
||||||
tag_id,
|
tag_id,
|
||||||
payload.name.as_deref(),
|
payload.name.as_deref(),
|
||||||
payload.description.as_deref(),
|
payload.description.as_deref(),
|
||||||
|
|
@ -116,7 +116,7 @@ pub async fn delete_tag(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(tag_id): Path<Uuid>,
|
Path(tag_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiErr> {
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
let deleted = crate::service::delete_tag(&state.pool, tag_id).await?;
|
let deleted = crate::service::delete_tag(&state.platform.pool, tag_id).await?;
|
||||||
|
|
||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(ApiErr::NotFound("Tag not found".to_string(), None));
|
return Err(ApiErr::NotFound("Tag not found".to_string(), None));
|
||||||
|
|
|
||||||
|
|
@ -5,145 +5,20 @@
|
||||||
},
|
},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// WebSocket message payload types.
|
pub use plc_platform_core::websocket::{
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
RoomManager, WebSocketManager, WsClientMessage, WsMessage,
|
||||||
#[serde(tag = "type", content = "data")]
|
};
|
||||||
pub enum WsMessage {
|
|
||||||
PointNewValue(crate::telemetry::PointMonitorInfo),
|
|
||||||
PointSetValueBatchResult(crate::connection::BatchSetPointValueRes),
|
|
||||||
EventCreated(plc_platform_core::model::EventRecord),
|
|
||||||
UnitRuntimeChanged(crate::control::runtime::UnitRuntime),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
|
||||||
pub enum WsClientMessage {
|
|
||||||
AuthWrite(WsAuthWriteReq),
|
|
||||||
PointSetValueBatch(crate::connection::BatchSetPointValueReq),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WsAuthWriteReq {
|
|
||||||
pub key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Room manager: room_id -> broadcast sender.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RoomManager {
|
|
||||||
rooms: Arc<RwLock<HashMap<String, broadcast::Sender<WsMessage>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoomManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
rooms: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get or create room sender.
|
|
||||||
pub async fn get_or_create_room(&self, room_id: &str) -> broadcast::Sender<WsMessage> {
|
|
||||||
let mut rooms = self.rooms.write().await;
|
|
||||||
|
|
||||||
if let Some(sender) = rooms.get(room_id) {
|
|
||||||
return sender.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (sender, _) = broadcast::channel(100);
|
|
||||||
rooms.insert(room_id.to_string(), sender.clone());
|
|
||||||
tracing::info!("Created new room: {}", room_id);
|
|
||||||
sender
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get room sender if room exists.
|
|
||||||
pub async fn get_room(&self, room_id: &str) -> Option<broadcast::Sender<WsMessage>> {
|
|
||||||
let rooms = self.rooms.read().await;
|
|
||||||
rooms.get(room_id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove room if there are no receivers left.
|
|
||||||
pub async fn remove_room_if_empty(&self, room_id: &str) {
|
|
||||||
let mut rooms = self.rooms.write().await;
|
|
||||||
let should_remove = rooms
|
|
||||||
.get(room_id)
|
|
||||||
.map(|sender| sender.receiver_count() == 0)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if should_remove {
|
|
||||||
rooms.remove(room_id);
|
|
||||||
tracing::info!("Removed empty room: {}", room_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send message to room.
|
|
||||||
///
|
|
||||||
/// Returns:
|
|
||||||
/// - Ok(n): n subscribers received it
|
|
||||||
/// - Ok(0): room missing or no active subscribers
|
|
||||||
pub async fn send_to_room(&self, room_id: &str, message: WsMessage) -> Result<usize, String> {
|
|
||||||
if let Some(sender) = self.get_room(room_id).await {
|
|
||||||
match sender.send(message) {
|
|
||||||
Ok(count) => Ok(count),
|
|
||||||
// No receiver is not exceptional in push scenarios.
|
|
||||||
Err(broadcast::error::SendError(_)) => Ok(0),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RoomManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WebSocket manager.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WebSocketManager {
|
|
||||||
public_room: Arc<RoomManager>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WebSocketManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
public_room: Arc::new(RoomManager::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send message to public room.
|
|
||||||
pub async fn send_to_public(&self, message: WsMessage) -> Result<usize, String> {
|
|
||||||
self.public_room.get_or_create_room("public").await;
|
|
||||||
self.public_room.send_to_room("public", message).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send message to a dedicated client room.
|
|
||||||
pub async fn send_to_client(&self, client_id: Uuid, message: WsMessage) -> Result<usize, String> {
|
|
||||||
self.public_room
|
|
||||||
.send_to_room(&client_id.to_string(), message)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WebSocketManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public websocket handler.
|
/// Public websocket handler.
|
||||||
pub async fn public_websocket_handler(
|
pub async fn public_websocket_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<crate::AppState>,
|
State(state): State<crate::AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ws_manager = state.ws_manager.clone();
|
let ws_manager = state.platform.ws_manager.clone();
|
||||||
let app_state = state.clone();
|
let app_state = state.clone();
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, "public".to_string(), app_state))
|
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, "public".to_string(), app_state))
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +29,7 @@ pub async fn client_websocket_handler(
|
||||||
Path(client_id): Path<Uuid>,
|
Path(client_id): Path<Uuid>,
|
||||||
State(state): State<crate::AppState>,
|
State(state): State<crate::AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ws_manager = state.ws_manager.clone();
|
let ws_manager = state.platform.ws_manager.clone();
|
||||||
let room_id = client_id.to_string();
|
let room_id = client_id.to_string();
|
||||||
let app_state = state.clone();
|
let app_state = state.clone();
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, room_id, app_state))
|
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, room_id, app_state))
|
||||||
|
|
@ -167,8 +42,7 @@ async fn handle_socket(
|
||||||
room_id: String,
|
room_id: String,
|
||||||
state: crate::AppState,
|
state: crate::AppState,
|
||||||
) {
|
) {
|
||||||
let room_sender = ws_manager.public_room.get_or_create_room(&room_id).await;
|
let mut rx = ws_manager.subscribe_room(&room_id).await;
|
||||||
let mut rx = room_sender.subscribe();
|
|
||||||
let mut can_write = false;
|
let mut can_write = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -198,7 +72,7 @@ async fn handle_socket(
|
||||||
results: vec![],
|
results: vec![],
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match state.connection_manager.write_point_values_batch(payload).await {
|
match state.platform.connection_manager.write_point_values_batch(payload).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => crate::connection::BatchSetPointValueRes {
|
Err(e) => crate::connection::BatchSetPointValueRes {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -214,7 +88,6 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = ws_manager
|
if let Err(e) = ws_manager
|
||||||
.public_room
|
|
||||||
.send_to_room(&room_id, WsMessage::PointSetValueBatchResult(response))
|
.send_to_room(&room_id, WsMessage::PointSetValueBatchResult(response))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -267,5 +140,5 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws_manager.public_room.remove_room_if_empty(&room_id).await;
|
ws_manager.remove_room_if_empty(&room_id).await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ axum = { version = "0.8", features = ["ws"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "json"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
use crate::router::build_router;
|
use crate::router::build_router;
|
||||||
|
use plc_platform_core::platform_context::PlatformContext;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
|
pub database_url: String,
|
||||||
pub server_host: String,
|
pub server_host: String,
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +11,8 @@ pub struct AppConfig {
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
database_url: std::env::var("DATABASE_URL")
|
||||||
|
.expect("DATABASE_URL must be set"),
|
||||||
server_host: std::env::var("OPS_SERVER_HOST")
|
server_host: std::env::var("OPS_SERVER_HOST")
|
||||||
.unwrap_or_else(|_| "127.0.0.1".to_string()),
|
.unwrap_or_else(|_| "127.0.0.1".to_string()),
|
||||||
server_port: std::env::var("OPS_SERVER_PORT")
|
server_port: std::env::var("OPS_SERVER_PORT")
|
||||||
|
|
@ -19,16 +23,16 @@ impl AppConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub app_name: &'static str,
|
pub app_name: &'static str,
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
|
pub platform: PlatformContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run() {
|
pub async fn run() {
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
plc_platform_core::util::log::init_logger();
|
plc_platform_core::util::log::init_logger();
|
||||||
let _platform = plc_platform_core::bootstrap::bootstrap_platform();
|
|
||||||
let _single_instance =
|
let _single_instance =
|
||||||
match plc_platform_core::util::single_instance::try_acquire("PLCControl.OperationSystem") {
|
match plc_platform_core::util::single_instance::try_acquire("PLCControl.OperationSystem") {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
|
|
@ -42,9 +46,16 @@ pub async fn run() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
let builder = plc_platform_core::bootstrap::bootstrap_platform(&config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to bootstrap platform");
|
||||||
|
let platform = builder.build();
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
app_name: "operation-system",
|
app_name: "operation-system",
|
||||||
config: AppConfig::from_env(),
|
config,
|
||||||
|
platform,
|
||||||
};
|
};
|
||||||
let app = build_router(state.clone());
|
let app = build_router(state.clone());
|
||||||
let addr = format!("{}:{}", state.config.server_host, state.config.server_port);
|
let addr = format!("{}:{}", state.config.server_host, state.config.server_port);
|
||||||
|
|
@ -59,11 +70,20 @@ pub async fn run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn test_state() -> AppState {
|
pub fn test_state() -> AppState {
|
||||||
|
let database_url = "postgres://plc:plc@localhost/plc_control_test".to_string();
|
||||||
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.connect_lazy(&database_url)
|
||||||
|
.expect("lazy pool should build");
|
||||||
|
let connection_manager = std::sync::Arc::new(plc_platform_core::connection::ConnectionManager::new());
|
||||||
|
let ws_manager = std::sync::Arc::new(plc_platform_core::websocket::WebSocketManager::new());
|
||||||
|
|
||||||
AppState {
|
AppState {
|
||||||
app_name: "operation-system",
|
app_name: "operation-system",
|
||||||
config: AppConfig {
|
config: AppConfig {
|
||||||
|
database_url,
|
||||||
server_host: "127.0.0.1".to_string(),
|
server_host: "127.0.0.1".to_string(),
|
||||||
server_port: 0,
|
server_port: 0,
|
||||||
},
|
},
|
||||||
|
platform: PlatformContext::new(pool, connection_manager, ws_manager),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,37 @@
|
||||||
use crate::platform_context::PlatformContext;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn bootstrap_platform() -> PlatformContext {
|
use crate::connection::ConnectionManager;
|
||||||
PlatformContext::new("bootstrap")
|
use crate::db::init_database;
|
||||||
|
use crate::platform_context::PlatformContext;
|
||||||
|
use crate::websocket::WebSocketManager;
|
||||||
|
|
||||||
|
pub struct PlatformBuilder {
|
||||||
|
pub pool: sqlx::PgPool,
|
||||||
|
pub connection_manager: ConnectionManager,
|
||||||
|
pub ws_manager: Arc<WebSocketManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlatformBuilder {
|
||||||
|
pub fn build(self) -> PlatformContext {
|
||||||
|
PlatformContext::new(
|
||||||
|
self.pool,
|
||||||
|
Arc::new(self.connection_manager),
|
||||||
|
self.ws_manager,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn bootstrap_platform(database_url: &str) -> Result<PlatformBuilder, String> {
|
||||||
|
let pool = init_database(database_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to initialize database: {}", e))?;
|
||||||
|
|
||||||
|
let connection_manager = ConnectionManager::new();
|
||||||
|
let ws_manager = Arc::new(WebSocketManager::new());
|
||||||
|
|
||||||
|
Ok(PlatformBuilder {
|
||||||
|
pool,
|
||||||
|
connection_manager,
|
||||||
|
ws_manager,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,25 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::connection::ConnectionManager;
|
||||||
|
use crate::websocket::WebSocketManager;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PlatformContext {
|
pub struct PlatformContext {
|
||||||
pub config_name: Arc<str>,
|
pub pool: sqlx::PgPool,
|
||||||
|
pub connection_manager: Arc<ConnectionManager>,
|
||||||
|
pub ws_manager: Arc<WebSocketManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformContext {
|
impl PlatformContext {
|
||||||
pub fn new(config_name: impl Into<Arc<str>>) -> Self {
|
pub fn new(
|
||||||
|
pool: sqlx::PgPool,
|
||||||
|
connection_manager: Arc<ConnectionManager>,
|
||||||
|
ws_manager: Arc<WebSocketManager>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config_name: config_name.into(),
|
pool,
|
||||||
|
connection_manager,
|
||||||
|
ws_manager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +119,19 @@ impl WebSocketManager {
|
||||||
.send_to_room(&client_id.to_string(), message)
|
.send_to_room(&client_id.to_string(), message)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn subscribe_room(&self, room_id: &str) -> broadcast::Receiver<WsMessage> {
|
||||||
|
let sender = self.public_room.get_or_create_room(room_id).await;
|
||||||
|
sender.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_to_room(&self, room_id: &str, message: WsMessage) -> Result<usize, String> {
|
||||||
|
self.public_room.send_to_room(room_id, message).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_room_if_empty(&self, room_id: &str) {
|
||||||
|
self.public_room.remove_room_if_empty(room_id).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WebSocketManager {
|
impl Default for WebSocketManager {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
use plc_platform_core::bootstrap::bootstrap_platform;
|
|
||||||
use plc_platform_core::platform_context::PlatformContext;
|
use plc_platform_core::platform_context::PlatformContext;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn platform_context_type_is_public() {
|
fn platform_context_type_is_public() {
|
||||||
let context = bootstrap_platform();
|
|
||||||
assert_eq!(context.config_name.as_ref(), "bootstrap");
|
|
||||||
|
|
||||||
fn assert_send_sync_clone<T: Send + Sync + Clone>() {}
|
fn assert_send_sync_clone<T: Send + Sync + Clone>() {}
|
||||||
assert_send_sync_clone::<PlatformContext>();
|
assert_send_sync_clone::<PlatformContext>();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div class="drawer-backdrop hidden" id="apiDocDrawer">
|
||||||
|
<aside class="drawer api-drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
|
||||||
|
<div class="drawer-head">
|
||||||
|
<h3 id="apiDocTitle">API.md</h3>
|
||||||
|
<button type="button" class="secondary" id="closeApiDoc">关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-body">
|
||||||
|
<aside class="doc-toc">
|
||||||
|
<div class="doc-toc-title">目录</div>
|
||||||
|
<div class="doc-toc-list" id="apiDocToc">加载中...</div>
|
||||||
|
</aside>
|
||||||
|
<div class="markdown-doc" id="apiDocContent">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<section class="panel bottom-right">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2 id="chartTitle">点位曲线</h2>
|
||||||
|
<button class="secondary" id="refreshChart">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart-panel">
|
||||||
|
<div class="muted" id="chartSummary">点击上方点位表中的一行查看曲线</div>
|
||||||
|
<canvas id="chartCanvas" class="chart-canvas" width="820" height="320"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<section class="panel top-left">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>设备</h2>
|
||||||
|
<button type="button" id="newEquipmentBtn">+ 新增</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar equipment-toolbar">
|
||||||
|
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
||||||
|
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="list equipment-list" id="equipmentList"></div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<section class="panel bottom-mid">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>实时日志</h2>
|
||||||
|
</div>
|
||||||
|
<div class="log" id="logView"></div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<section class="panel ops-bottom">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>系统事件</h2>
|
||||||
|
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="list event-list" id="eventList"></div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
<div class="modal hidden" id="equipmentModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>设备配置</h3>
|
||||||
|
<button class="secondary" id="closeEquipmentModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="equipmentForm" class="form">
|
||||||
|
<input type="hidden" id="equipmentId" />
|
||||||
|
<label>
|
||||||
|
编码
|
||||||
|
<input id="equipmentCode" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input id="equipmentName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
类型
|
||||||
|
<select id="equipmentKind"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
说明
|
||||||
|
<input id="equipmentDescription" />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="equipmentReset">清空</button>
|
||||||
|
<button type="submit" id="equipmentSubmit">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="pointModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>选择节点创建点位</h3>
|
||||||
|
<button class="secondary" id="closeModal">X</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select id="pointSourceSelect"></select>
|
||||||
|
<div class="muted" id="pointSourceNodeCount">节点: 0</div>
|
||||||
|
<button id="browseNodes">加载节点</button>
|
||||||
|
<button class="secondary" id="refreshTree">刷新树</button>
|
||||||
|
</div>
|
||||||
|
<div class="tree" id="nodeTree"></div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||||
|
<button id="createPoints">创建设备点位</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="sourceModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Source 配置</h3>
|
||||||
|
<button class="secondary" id="closeSourceModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="sourceForm" class="form">
|
||||||
|
<input type="hidden" id="sourceId" />
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input id="sourceName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Endpoint
|
||||||
|
<input id="sourceEndpoint" placeholder="opc.tcp://host:port" required />
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" id="sourceEnabled" checked />
|
||||||
|
<span>启用</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="sourceReset">清空</button>
|
||||||
|
<button type="submit" id="sourceSubmit">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="pointBindingModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>绑定点位</h3>
|
||||||
|
<button class="secondary" id="closePointBindingModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="pointBindingForm" class="form">
|
||||||
|
<input type="hidden" id="bindingPointId" />
|
||||||
|
<label>
|
||||||
|
点位
|
||||||
|
<input id="bindingPointName" disabled />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
设备
|
||||||
|
<select id="bindingEquipmentId"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色模板
|
||||||
|
<select id="bindingSignalRole"></select>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="clearPointBinding">清空绑定</button>
|
||||||
|
<button type="submit" id="savePointBinding">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="batchBindingModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>批量绑定点位</h3>
|
||||||
|
<button class="secondary" id="closeBatchBindingModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="batchBindingForm" class="form">
|
||||||
|
<div class="muted" id="batchBindingSummary">已选中 0 个点位</div>
|
||||||
|
<label>
|
||||||
|
设备
|
||||||
|
<select id="batchBindingEquipmentId"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色模板
|
||||||
|
<select id="batchBindingSignalRole"></select>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="clearBatchBinding">清空设备和角色</button>
|
||||||
|
<button type="submit" id="saveBatchBinding">批量保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<section class="panel top-right">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>点位</h2>
|
||||||
|
<div class="pager">
|
||||||
|
<button class="secondary" id="prevPoints" title="上一页">‹</button>
|
||||||
|
<span id="pointsPageInfo">1 / 1</span>
|
||||||
|
<button class="secondary" id="nextPoints" title="下一页">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar point-batch-toolbar">
|
||||||
|
<label class="check-row compact-check">
|
||||||
|
<input type="checkbox" id="toggleAllPoints" />
|
||||||
|
<span>本页全选</span>
|
||||||
|
</label>
|
||||||
|
<div class="muted" id="pointFilterSummary">当前筛选: 全部点位</div>
|
||||||
|
<div class="muted" id="selectedPointCount">已选中 0 个点位</div>
|
||||||
|
<button type="button" class="secondary" id="openPointModal">选入节点</button>
|
||||||
|
<button type="button" class="secondary" id="openBatchBinding">批量绑定设备</button>
|
||||||
|
<button type="button" class="secondary" id="clearSelectedPoints">清空选择</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:6%"></th>
|
||||||
|
<th style="width:22%">名称</th>
|
||||||
|
<th style="width:16%">值</th>
|
||||||
|
<th style="width:10%">质量</th>
|
||||||
|
<th style="width:18%">设备/角色</th>
|
||||||
|
<th style="width:21%">更新时间</th>
|
||||||
|
<th style="width:120px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pointList"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<section class="panel bottom-left">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>数据源</h2>
|
||||||
|
<button type="button" id="openSourceForm">+ 新增</button>
|
||||||
|
</div>
|
||||||
|
<div class="source-panels" id="sourceList"></div>
|
||||||
|
</section>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,135 @@
|
||||||
|
<div class="modal hidden" id="equipmentModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>设备配置</h3>
|
||||||
|
<button class="secondary" id="closeEquipmentModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="equipmentForm" class="form">
|
||||||
|
<input type="hidden" id="equipmentId" />
|
||||||
|
<label>
|
||||||
|
所属单元
|
||||||
|
<select id="equipmentUnitId"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
编码
|
||||||
|
<input id="equipmentCode" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input id="equipmentName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
类型
|
||||||
|
<select id="equipmentKind"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
说明
|
||||||
|
<input id="equipmentDescription" />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="equipmentReset">清空</button>
|
||||||
|
<button type="submit" id="equipmentSubmit">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="pointModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>选择节点创建点位</h3>
|
||||||
|
<button class="secondary" id="closeModal">X</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select id="pointSourceSelect"></select>
|
||||||
|
<div class="muted" id="pointSourceNodeCount">节点: 0</div>
|
||||||
|
<button id="browseNodes">加载节点</button>
|
||||||
|
<button class="secondary" id="refreshTree">刷新树</button>
|
||||||
|
</div>
|
||||||
|
<div class="tree" id="nodeTree"></div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||||
|
<button id="createPoints">创建设备点位</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="sourceModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Source 配置</h3>
|
||||||
|
<button class="secondary" id="closeSourceModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="sourceForm" class="form">
|
||||||
|
<input type="hidden" id="sourceId" />
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input id="sourceName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Endpoint
|
||||||
|
<input id="sourceEndpoint" placeholder="opc.tcp://host:port" required />
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" id="sourceEnabled" checked />
|
||||||
|
<span>启用</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="sourceReset">清空</button>
|
||||||
|
<button type="submit" id="sourceSubmit">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="pointBindingModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>绑定点位</h3>
|
||||||
|
<button class="secondary" id="closePointBindingModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="pointBindingForm" class="form">
|
||||||
|
<input type="hidden" id="bindingPointId" />
|
||||||
|
<label>
|
||||||
|
点位
|
||||||
|
<input id="bindingPointName" disabled />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
设备
|
||||||
|
<select id="bindingEquipmentId"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色模板
|
||||||
|
<select id="bindingSignalRole"></select>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="clearPointBinding">清空绑定</button>
|
||||||
|
<button type="submit" id="savePointBinding">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="batchBindingModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>批量绑定点位</h3>
|
||||||
|
<button class="secondary" id="closeBatchBindingModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="batchBindingForm" class="form">
|
||||||
|
<div class="muted" id="batchBindingSummary">已选中 0 个点位</div>
|
||||||
|
<label>
|
||||||
|
设备
|
||||||
|
<select id="batchBindingEquipmentId"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色模板
|
||||||
|
<select id="batchBindingSignalRole"></select>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="clearBatchBinding">清空设备和角色</button>
|
||||||
|
<button type="submit" id="saveBatchBinding">批量保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<section class="panel ops-main">
|
||||||
|
<div class="ops-layout">
|
||||||
|
<aside class="ops-unit-sidebar">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>控制单元</h2>
|
||||||
|
<div class="ops-batch-actions">
|
||||||
|
<button type="button" class="secondary" id="batchStartAutoBtn" title="启动所有未锁定单元的自动控制">全部启动</button>
|
||||||
|
<button type="button" class="danger" id="batchStopAutoBtn" title="停止所有单元的自动控制">全部停止</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list ops-unit-list" id="opsUnitList"></div>
|
||||||
|
</aside>
|
||||||
|
<div class="ops-equipment-area" id="opsEquipmentArea">
|
||||||
|
<div class="muted ops-placeholder">← 选择控制单元</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="title">投煤器布料机控制系统</div>
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button type="button" class="tab-btn active" id="tabOps">运维</button>
|
||||||
|
<button type="button" class="tab-btn" id="tabAppConfig">应用配置</button>
|
||||||
|
<button type="button" class="tab-btn" id="tabConfig">平台配置</button>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button type="button" class="secondary" id="openReadmeDoc">README.md</button>
|
||||||
|
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||||
|
<div class="status" id="statusText">
|
||||||
|
<span class="ws-dot" id="wsDot"></span>
|
||||||
|
<span id="wsLabel">连接中…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<div class="modal hidden" id="unitModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>控制单元配置</h3>
|
||||||
|
<button class="secondary" id="closeUnitModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="unitForm" class="form">
|
||||||
|
<input type="hidden" id="unitId" />
|
||||||
|
<label>
|
||||||
|
编码
|
||||||
|
<input id="unitCode" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input id="unitName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
说明
|
||||||
|
<input id="unitDescription" />
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" id="unitEnabled" checked />
|
||||||
|
<span>启用</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
投煤运行时间(秒)
|
||||||
|
<input id="unitRunTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
投煤停止时间(秒)
|
||||||
|
<input id="unitStopTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
投煤累计阈值(秒)
|
||||||
|
<input id="unitAccTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
布料机运行时间(秒)
|
||||||
|
<input id="unitBlTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" id="unitManualAck" checked />
|
||||||
|
<span>故障恢复后需人工确认</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="unitReset">清空</button>
|
||||||
|
<button type="submit" id="unitSubmit">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<section class="panel app-config-main">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>控制单元配置</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" class="secondary" id="refreshUnitBtn2">刷新</button>
|
||||||
|
<button type="button" id="newUnitBtn2">+ 新增</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list unit-config-list" id="unitConfigList"></div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>PLC Control</title>
|
||||||
|
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div data-partial="/ui/html/topbar.html"></div>
|
||||||
|
|
||||||
|
<main class="grid-ops">
|
||||||
|
<div data-partial="/ui/html/ops-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/equipment-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/points-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/source-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/log-stream-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/chart-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/unit-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/logs-panel.html"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div data-partial="/ui/html/modals.html"></div>
|
||||||
|
<div data-partial="/ui/html/unit-modal.html"></div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="unitEquipmentModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>选择设备</h3>
|
||||||
|
<button class="secondary" id="closeUnitEquipmentModal">X</button>
|
||||||
|
</div>
|
||||||
|
<div id="unitEquipmentList" style="max-height:400px;overflow:auto"></div>
|
||||||
|
<div class="form-actions" style="padding:10px">
|
||||||
|
<button type="button" class="secondary" id="cancelUnitEquipment">取消</button>
|
||||||
|
<button type="button" id="confirmUnitEquipment">确认绑定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
|
||||||
|
export function setStatus(text) {
|
||||||
|
dom.statusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function getContainer() {
|
||||||
|
let el = document.getElementById("toast-container");
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.id = "toast-container";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS = { error: "✕", warning: "!", success: "✓", info: "i" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示 toast 通知。
|
||||||
|
* @param {string} title 主要文字
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.message] 次要说明文字
|
||||||
|
* @param {"error"|"warning"|"success"|"info"} [opts.level="error"]
|
||||||
|
* @param {number} [opts.duration=4000] 自动关闭毫秒数,0 表示不自动关闭
|
||||||
|
* @param {boolean} [opts.shake=false] 出现时加抖动动画
|
||||||
|
* @returns {{ dismiss: () => void }}
|
||||||
|
*/
|
||||||
|
export function showToast(title, { message, level = "error", duration = 4000, shake = false } = {}) {
|
||||||
|
const container = getContainer();
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = `toast ${level}${shake ? " shake" : ""}`;
|
||||||
|
el.innerHTML = `
|
||||||
|
<span class="toast-icon">${ICONS[level] ?? "i"}</span>
|
||||||
|
<div class="toast-body">
|
||||||
|
<div class="toast-title">${title}</div>
|
||||||
|
${message ? `<div class="toast-message">${message}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
if (!el.parentNode) return;
|
||||||
|
el.classList.remove("shake");
|
||||||
|
el.classList.add("hiding");
|
||||||
|
el.addEventListener("animationend", () => el.remove(), { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener("click", dismiss);
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
if (duration > 0) setTimeout(dismiss, duration);
|
||||||
|
return { dismiss };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── apiFetch ──────────────────────────────────────
|
||||||
|
|
||||||
|
export async function apiFetch(url, options = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = (await response.text()) || response.statusText;
|
||||||
|
showToast(`请求失败 ${response.status}`, { message: text });
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withStatus(task) {
|
||||||
|
return task.catch((error) => {
|
||||||
|
setStatus(error.message || "请求失败");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { withStatus } from "./api.js";
|
||||||
|
import { openChart, renderChart } from "./chart.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./docs.js";
|
||||||
|
import { loadEvents } from "./events.js";
|
||||||
|
import {
|
||||||
|
clearPointBinding,
|
||||||
|
closeEquipmentModal,
|
||||||
|
loadEquipments,
|
||||||
|
openCreateEquipmentModal,
|
||||||
|
resetEquipmentForm,
|
||||||
|
saveEquipment,
|
||||||
|
} from "./equipment.js";
|
||||||
|
import { startPointSocket, startLogs, stopLogs } from "./logs.js";
|
||||||
|
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
|
||||||
|
import {
|
||||||
|
clearBatchBinding,
|
||||||
|
browseAndLoadTree,
|
||||||
|
clearSelectedPoints,
|
||||||
|
createPoints,
|
||||||
|
loadPoints,
|
||||||
|
loadTree,
|
||||||
|
openBatchBinding,
|
||||||
|
openPointCreateModal,
|
||||||
|
renderSelectedNodes,
|
||||||
|
saveBatchBinding,
|
||||||
|
savePointBinding,
|
||||||
|
updatePointFilterSummary,
|
||||||
|
updateSelectedPointSummary,
|
||||||
|
} from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { loadSources, saveSource } from "./sources.js";
|
||||||
|
import { bindUnitEquipmentModalEvents, closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
|
||||||
|
|
||||||
|
let _configLoaded = false;
|
||||||
|
let _appConfigLoaded = false;
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
state.activeView = view;
|
||||||
|
const main = document.querySelector("main");
|
||||||
|
main.className =
|
||||||
|
view === "ops" ? "grid-ops" :
|
||||||
|
view === "app-config" ? "grid-app-config" :
|
||||||
|
"grid-config";
|
||||||
|
|
||||||
|
dom.tabOps.classList.toggle("active", view === "ops");
|
||||||
|
dom.tabAppConfig.classList.toggle("active", view === "app-config");
|
||||||
|
dom.tabConfig.classList.toggle("active", view === "config");
|
||||||
|
|
||||||
|
// config-only panels (platform config view)
|
||||||
|
["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => {
|
||||||
|
const el = main.querySelector(`.panel.${cls}`);
|
||||||
|
if (el) el.classList.toggle("hidden", view !== "config");
|
||||||
|
});
|
||||||
|
const logStreamPanel = main.querySelector(".panel.bottom-mid");
|
||||||
|
if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view !== "config");
|
||||||
|
|
||||||
|
// ops-only panels
|
||||||
|
const opsMain = main.querySelector(".panel.ops-main");
|
||||||
|
const opsBottom = main.querySelector(".panel.ops-bottom");
|
||||||
|
if (opsMain) opsMain.classList.toggle("hidden", view !== "ops");
|
||||||
|
if (opsBottom) opsBottom.classList.toggle("hidden", view !== "ops");
|
||||||
|
|
||||||
|
// app-config-only panels
|
||||||
|
const appConfigMain = main.querySelector(".panel.app-config-main");
|
||||||
|
if (appConfigMain) appConfigMain.classList.toggle("hidden", view !== "app-config");
|
||||||
|
|
||||||
|
if (view === "config") {
|
||||||
|
startLogs();
|
||||||
|
if (!_configLoaded) {
|
||||||
|
_configLoaded = true;
|
||||||
|
withStatus((async () => {
|
||||||
|
await Promise.all([loadSources(), loadEquipments(), loadEvents()]);
|
||||||
|
await loadPoints();
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === "app-config") {
|
||||||
|
if (!_appConfigLoaded) {
|
||||||
|
_appConfigLoaded = true;
|
||||||
|
withStatus(Promise.all([loadUnits(), loadEquipments()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
||||||
|
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
||||||
|
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
|
||||||
|
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
|
||||||
|
dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event)));
|
||||||
|
|
||||||
|
dom.unitResetBtn.addEventListener("click", resetUnitForm);
|
||||||
|
if (dom.refreshUnitBtn) dom.refreshUnitBtn.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
|
||||||
|
if (dom.newUnitBtn) dom.newUnitBtn.addEventListener("click", openCreateUnitModal);
|
||||||
|
dom.closeUnitModalBtn.addEventListener("click", closeUnitModal);
|
||||||
|
|
||||||
|
dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset());
|
||||||
|
dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
|
||||||
|
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
|
||||||
|
dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal);
|
||||||
|
dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal);
|
||||||
|
dom.openPointModalBtn.addEventListener("click", openPointCreateModal);
|
||||||
|
dom.pointSourceSelect.addEventListener("change", () => {
|
||||||
|
dom.nodeTree.innerHTML = '<div class="muted">点击"加载节点"获取节点树</div>';
|
||||||
|
dom.pointSourceNodeCount.textContent = "节点: 0";
|
||||||
|
});
|
||||||
|
dom.browseNodesBtn.addEventListener("click", () => withStatus(browseAndLoadTree()));
|
||||||
|
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.openBatchBindingBtn.addEventListener("click", openBatchBinding);
|
||||||
|
dom.clearSelectedPointsBtn.addEventListener("click", clearSelectedPoints);
|
||||||
|
dom.closeBatchBindingModalBtn.addEventListener("click", () => {
|
||||||
|
dom.batchBindingModal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
dom.clearBatchBindingBtn.addEventListener("click", () => withStatus(clearBatchBinding()));
|
||||||
|
|
||||||
|
dom.toggleAllPoints.addEventListener("change", () => {
|
||||||
|
const checked = dom.toggleAllPoints.checked;
|
||||||
|
dom.pointList.querySelectorAll('input[data-point-select="true"]').forEach((input) => {
|
||||||
|
input.checked = checked;
|
||||||
|
input.dispatchEvent(new Event("change"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.openReadmeDocBtn.addEventListener("click", () => withStatus(openReadmeDrawer()));
|
||||||
|
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
|
||||||
|
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
|
||||||
|
dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.tabOps.addEventListener("click", () => switchView("ops"));
|
||||||
|
dom.tabAppConfig.addEventListener("click", () => switchView("app-config"));
|
||||||
|
dom.tabConfig.addEventListener("click", () => switchView("config"));
|
||||||
|
|
||||||
|
dom.refreshUnitBtn2.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
|
||||||
|
dom.newUnitBtn2.addEventListener("click", openCreateUnitModal);
|
||||||
|
bindUnitEquipmentModalEvents();
|
||||||
|
|
||||||
|
document.addEventListener("equipments-updated", () => {
|
||||||
|
renderUnits();
|
||||||
|
// Re-fetch units so embedded equipment data stays in sync with config changes.
|
||||||
|
loadUnits().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("units-loaded", () => {
|
||||||
|
renderOpsUnits();
|
||||||
|
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
bindEvents();
|
||||||
|
switchView("ops");
|
||||||
|
renderSelectedNodes();
|
||||||
|
updateSelectedPointSummary();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
renderChart();
|
||||||
|
startPointSocket();
|
||||||
|
|
||||||
|
await withStatus(Promise.all([loadUnits(), loadEvents()]));
|
||||||
|
startOps();
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function normalizeChartItem(item) {
|
||||||
|
let valueNumber = null;
|
||||||
|
if (typeof item?.value_number === "number" && Number.isFinite(item.value_number)) {
|
||||||
|
valueNumber = item.value_number;
|
||||||
|
} else if (typeof item?.value === "number" && Number.isFinite(item.value)) {
|
||||||
|
valueNumber = item.value;
|
||||||
|
} else if (typeof item?.value === "boolean") {
|
||||||
|
valueNumber = item.value ? 1 : 0;
|
||||||
|
} else if (typeof item?.value?.float === "number" && Number.isFinite(item.value.float)) {
|
||||||
|
valueNumber = item.value.float;
|
||||||
|
} else if (typeof item?.value?.int === "number" && Number.isFinite(item.value.int)) {
|
||||||
|
valueNumber = item.value.int;
|
||||||
|
} else if (typeof item?.value?.uint === "number" && Number.isFinite(item.value.uint)) {
|
||||||
|
valueNumber = item.value.uint;
|
||||||
|
} else if (typeof item?.value?.bool === "boolean") {
|
||||||
|
valueNumber = item.value.bool ? 1 : 0;
|
||||||
|
} else if (typeof item?.value_text === "string") {
|
||||||
|
const parsed = Number(item.value_text);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
valueNumber = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: item?.timestamp || "",
|
||||||
|
valueNumber,
|
||||||
|
valueText: item?.value_text || (valueNumber === null ? "" : String(valueNumber)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisValue(value) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
if (Math.abs(value) >= 1000 || Math.abs(value) < 0.01) {
|
||||||
|
return value.toExponential(2);
|
||||||
|
}
|
||||||
|
return value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeLabel(timestamp) {
|
||||||
|
if (!timestamp) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
const match = String(timestamp).match(/(\d{2}:\d{2}:\d{2})/);
|
||||||
|
return match ? match[1] : String(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(normalizeChartItem)
|
||||||
|
.filter((item) => item.valueNumber !== null);
|
||||||
|
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendChartPoint(item) {
|
||||||
|
if (!state.chartPointId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeChartItem(item);
|
||||||
|
if (normalized.valueNumber === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = state.chartData[state.chartData.length - 1];
|
||||||
|
if (
|
||||||
|
last &&
|
||||||
|
last.timestamp === normalized.timestamp &&
|
||||||
|
last.valueText === normalized.valueText &&
|
||||||
|
last.valueNumber === normalized.valueNumber
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.chartData.push(normalized);
|
||||||
|
if (state.chartData.length > 120) {
|
||||||
|
state.chartData = state.chartData.slice(-120);
|
||||||
|
}
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderChart() {
|
||||||
|
const ctx = dom.chartCanvas.getContext("2d");
|
||||||
|
const width = dom.chartCanvas.width;
|
||||||
|
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("点击点位行查看图表", 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: 42, left: 64 };
|
||||||
|
const plotWidth = width - padding.left - padding.right;
|
||||||
|
const plotHeight = height - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
ctx.strokeStyle = "#cbd5e1";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 4; i += 1) {
|
||||||
|
const y = padding.top + (plotHeight / 4) * i;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, y);
|
||||||
|
ctx.lineTo(width - padding.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, padding.top);
|
||||||
|
ctx.lineTo(padding.left, height - padding.bottom);
|
||||||
|
ctx.lineTo(width - padding.right, height - padding.bottom);
|
||||||
|
ctx.strokeStyle = "#94a3b8";
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "#64748b";
|
||||||
|
ctx.font = "12px Segoe UI";
|
||||||
|
for (let i = 0; i <= 4; i += 1) {
|
||||||
|
const value = max - ((max - min) / 4) * i;
|
||||||
|
const y = padding.top + (plotHeight / 4) * i;
|
||||||
|
ctx.fillText(formatAxisValue(value), 8, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLabel = formatTimeLabel(state.chartData[0]?.timestamp);
|
||||||
|
const middleLabel = formatTimeLabel(
|
||||||
|
state.chartData[Math.floor((state.chartData.length - 1) / 2)]?.timestamp,
|
||||||
|
);
|
||||||
|
const lastLabel = formatTimeLabel(state.chartData[state.chartData.length - 1]?.timestamp);
|
||||||
|
|
||||||
|
ctx.fillText(firstLabel, padding.left, height - 12);
|
||||||
|
const middleWidth = ctx.measureText(middleLabel).width;
|
||||||
|
ctx.fillText(middleLabel, padding.left + plotWidth / 2 - middleWidth / 2, height - 12);
|
||||||
|
const lastWidth = ctx.measureText(lastLabel).width;
|
||||||
|
ctx.fillText(lastLabel, width - padding.right - lastWidth, height - 12);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(16, padding.top + plotHeight / 2);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
|
ctx.fillStyle = "#64748b";
|
||||||
|
ctx.fillText("数值", 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
ctx.fillText("时间", width / 2 - 12, height - 28);
|
||||||
|
|
||||||
|
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 = `Latest ${state.chartData.length} points, current value ${latest.valueText || latest.valueNumber}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return text
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\u4e00-\u9fa5]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdown(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const blocks = [];
|
||||||
|
const headings = [];
|
||||||
|
let inCode = false;
|
||||||
|
let codeBuffer = [];
|
||||||
|
let paragraph = [];
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (!paragraph.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push(`<p>${escapeHtml(paragraph.join(" "))}</p>`);
|
||||||
|
paragraph = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushCode = () => {
|
||||||
|
if (!codeBuffer.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push(`<pre><code>${escapeHtml(codeBuffer.join("\n"))}</code></pre>`);
|
||||||
|
codeBuffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.startsWith("```")) {
|
||||||
|
if (inCode) {
|
||||||
|
flushCode();
|
||||||
|
} else {
|
||||||
|
flushParagraph();
|
||||||
|
}
|
||||||
|
inCode = !inCode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCode) {
|
||||||
|
codeBuffer.push(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,4})\s+(.*)$/);
|
||||||
|
if (heading) {
|
||||||
|
flushParagraph();
|
||||||
|
const level = heading[1].length;
|
||||||
|
const textValue = heading[2].trim();
|
||||||
|
const id = slugify(textValue);
|
||||||
|
headings.push({ level, text: textValue, id });
|
||||||
|
blocks.push(`<h${level} id="${id}">${escapeHtml(textValue)}</h${level}>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
flushParagraph();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraph.push(line.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
flushCode();
|
||||||
|
|
||||||
|
return { html: blocks.join(""), headings };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDoc(url, emptyMessage) {
|
||||||
|
const text = await apiFetch(url);
|
||||||
|
const { html, headings } = parseMarkdown(text || "");
|
||||||
|
|
||||||
|
dom.apiDocContent.innerHTML = html || `<p>${emptyMessage}</p>`;
|
||||||
|
dom.apiDocToc.innerHTML = headings.length
|
||||||
|
? headings
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<a class="doc-toc-item level-${item.level}" href="#${item.id}">${escapeHtml(item.text)}</a>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: "<div class=\"muted\">未解析到标题</div>";
|
||||||
|
|
||||||
|
dom.apiDocToc.querySelectorAll("a").forEach((link) => {
|
||||||
|
link.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = link.getAttribute("href")?.slice(1);
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = dom.apiDocContent.querySelector(`#${CSS.escape(id)}`);
|
||||||
|
if (target) {
|
||||||
|
const offset = target.getBoundingClientRect().top - dom.apiDocContent.getBoundingClientRect().top;
|
||||||
|
dom.apiDocContent.scrollBy({ top: offset, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openApiDocDrawer() {
|
||||||
|
const title = dom.apiDocDrawer.querySelector("h3");
|
||||||
|
if (title) title.textContent = "API.md";
|
||||||
|
dom.apiDocDrawer.classList.remove("hidden");
|
||||||
|
if (state.docDrawerSource !== "api") {
|
||||||
|
state.docDrawerSource = "api";
|
||||||
|
await loadDoc("/api/docs/api-md", "API.md 为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openReadmeDrawer() {
|
||||||
|
const title = dom.apiDocDrawer.querySelector("h3");
|
||||||
|
if (title) title.textContent = "README.md";
|
||||||
|
dom.apiDocDrawer.classList.remove("hidden");
|
||||||
|
if (state.docDrawerSource !== "readme") {
|
||||||
|
state.docDrawerSource = "readme";
|
||||||
|
await loadDoc("/api/docs/readme-md", "README.md 为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeApiDocDrawer() {
|
||||||
|
dom.apiDocDrawer.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
export const dom = {
|
||||||
|
statusText: byId("statusText"),
|
||||||
|
wsDot: byId("wsDot"),
|
||||||
|
wsLabel: byId("wsLabel"),
|
||||||
|
batchStartAutoBtn: byId("batchStartAutoBtn"),
|
||||||
|
batchStopAutoBtn: byId("batchStopAutoBtn"),
|
||||||
|
tabOps: byId("tabOps"),
|
||||||
|
tabAppConfig: byId("tabAppConfig"),
|
||||||
|
tabConfig: byId("tabConfig"),
|
||||||
|
opsUnitList: byId("opsUnitList"),
|
||||||
|
opsEquipmentArea: byId("opsEquipmentArea"),
|
||||||
|
logView: byId("logView"),
|
||||||
|
sourceList: byId("sourceList"),
|
||||||
|
unitList: byId("unitList"),
|
||||||
|
eventList: byId("eventList"),
|
||||||
|
nodeTree: byId("nodeTree"),
|
||||||
|
pointList: byId("pointList"),
|
||||||
|
pointsPageInfo: byId("pointsPageInfo"),
|
||||||
|
selectedCount: byId("selectedCount"),
|
||||||
|
selectedPointCount: byId("selectedPointCount"),
|
||||||
|
pointFilterSummary: byId("pointFilterSummary"),
|
||||||
|
pointSourceSelect: byId("pointSourceSelect"),
|
||||||
|
pointSourceNodeCount: byId("pointSourceNodeCount"),
|
||||||
|
openPointModalBtn: byId("openPointModal"),
|
||||||
|
chartCanvas: byId("chartCanvas"),
|
||||||
|
chartTitle: byId("chartTitle"),
|
||||||
|
chartSummary: byId("chartSummary"),
|
||||||
|
pointModal: byId("pointModal"),
|
||||||
|
unitModal: byId("unitModal"),
|
||||||
|
sourceModal: byId("sourceModal"),
|
||||||
|
equipmentModal: byId("equipmentModal"),
|
||||||
|
pointBindingModal: byId("pointBindingModal"),
|
||||||
|
batchBindingModal: byId("batchBindingModal"),
|
||||||
|
apiDocDrawer: byId("apiDocDrawer"),
|
||||||
|
unitForm: byId("unitForm"),
|
||||||
|
unitId: byId("unitId"),
|
||||||
|
unitCode: byId("unitCode"),
|
||||||
|
unitName: byId("unitName"),
|
||||||
|
unitDescription: byId("unitDescription"),
|
||||||
|
unitEnabled: byId("unitEnabled"),
|
||||||
|
unitRunTimeSec: byId("unitRunTimeSec"),
|
||||||
|
unitStopTimeSec: byId("unitStopTimeSec"),
|
||||||
|
unitAccTimeSec: byId("unitAccTimeSec"),
|
||||||
|
unitBlTimeSec: byId("unitBlTimeSec"),
|
||||||
|
unitManualAck: byId("unitManualAck"),
|
||||||
|
unitResetBtn: byId("unitReset"),
|
||||||
|
sourceForm: byId("sourceForm"),
|
||||||
|
sourceId: byId("sourceId"),
|
||||||
|
sourceName: byId("sourceName"),
|
||||||
|
sourceEndpoint: byId("sourceEndpoint"),
|
||||||
|
sourceEnabled: byId("sourceEnabled"),
|
||||||
|
sourceResetBtn: byId("sourceReset"),
|
||||||
|
equipmentForm: byId("equipmentForm"),
|
||||||
|
equipmentId: byId("equipmentId"),
|
||||||
|
equipmentUnitId: byId("equipmentUnitId"),
|
||||||
|
equipmentCode: byId("equipmentCode"),
|
||||||
|
equipmentName: byId("equipmentName"),
|
||||||
|
equipmentKind: byId("equipmentKind"),
|
||||||
|
equipmentDescription: byId("equipmentDescription"),
|
||||||
|
equipmentResetBtn: byId("equipmentReset"),
|
||||||
|
equipmentKeyword: byId("equipmentKeyword"),
|
||||||
|
equipmentList: byId("equipmentList"),
|
||||||
|
refreshUnitBtn: byId("refreshUnitBtn"),
|
||||||
|
newUnitBtn: byId("newUnitBtn"),
|
||||||
|
refreshUnitBtn2: byId("refreshUnitBtn2"),
|
||||||
|
newUnitBtn2: byId("newUnitBtn2"),
|
||||||
|
unitConfigList: byId("unitConfigList"),
|
||||||
|
unitEquipmentModal: byId("unitEquipmentModal"),
|
||||||
|
unitEquipmentList: byId("unitEquipmentList"),
|
||||||
|
closeUnitEquipmentModalBtn: byId("closeUnitEquipmentModal"),
|
||||||
|
cancelUnitEquipmentBtn: byId("cancelUnitEquipment"),
|
||||||
|
confirmUnitEquipmentBtn: byId("confirmUnitEquipment"),
|
||||||
|
closeUnitModalBtn: byId("closeUnitModal"),
|
||||||
|
closeEquipmentModalBtn: byId("closeEquipmentModal"),
|
||||||
|
refreshEventBtn: byId("refreshEventBtn"),
|
||||||
|
pointBindingForm: byId("pointBindingForm"),
|
||||||
|
bindingPointId: byId("bindingPointId"),
|
||||||
|
bindingPointName: byId("bindingPointName"),
|
||||||
|
bindingEquipmentId: byId("bindingEquipmentId"),
|
||||||
|
bindingSignalRole: byId("bindingSignalRole"),
|
||||||
|
batchBindingForm: byId("batchBindingForm"),
|
||||||
|
batchBindingSummary: byId("batchBindingSummary"),
|
||||||
|
batchBindingEquipmentId: byId("batchBindingEquipmentId"),
|
||||||
|
batchBindingSignalRole: byId("batchBindingSignalRole"),
|
||||||
|
apiDocToc: byId("apiDocToc"),
|
||||||
|
apiDocContent: byId("apiDocContent"),
|
||||||
|
openReadmeDocBtn: byId("openReadmeDoc"),
|
||||||
|
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"),
|
||||||
|
toggleAllPoints: byId("toggleAllPoints"),
|
||||||
|
openBatchBindingBtn: byId("openBatchBinding"),
|
||||||
|
clearSelectedPointsBtn: byId("clearSelectedPoints"),
|
||||||
|
closeBatchBindingModalBtn: byId("closeBatchBindingModal"),
|
||||||
|
clearBatchBindingBtn: byId("clearBatchBinding"),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { renderEquipmentKindOptions, renderRoleOptions } from "./roles.js";
|
||||||
|
import { clearSelectedPoints, loadPoints, updatePointFilterSummary } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function equipmentOf(item) {
|
||||||
|
return item && item.equipment ? item.equipment : item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUnitLabel(unitId) {
|
||||||
|
if (!unitId) {
|
||||||
|
return "未绑定单元";
|
||||||
|
}
|
||||||
|
const unit = state.unitMap.get(unitId);
|
||||||
|
return unit ? `${unit.code} / ${unit.name}` : "未知单元";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredEquipments() {
|
||||||
|
if (!state.selectedUnitId) {
|
||||||
|
return state.equipments;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.equipments.filter((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
return equipment.unit_id === state.selectedUnitId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEquipmentUnitOptions(selected = "", target = dom.equipmentUnitId) {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ['<option value="">未绑定单元</option>'];
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const isSelected = unit.id === selected ? "selected" : "";
|
||||||
|
options.push(`<option value="${unit.id}" ${isSelected}>${unit.code} / ${unit.name}</option>`);
|
||||||
|
});
|
||||||
|
target.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
|
||||||
|
const options = ['<option value="">未绑定</option>'];
|
||||||
|
filteredEquipments().forEach((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
const isSelected = equipment.id === selected ? "selected" : "";
|
||||||
|
options.push(
|
||||||
|
`<option value="${equipment.id}" ${isSelected}>${equipment.code} / ${equipment.name}</option>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
target.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBatchBindingDefaults() {
|
||||||
|
renderBindingEquipmentOptions("", dom.batchBindingEquipmentId);
|
||||||
|
dom.batchBindingSignalRole.innerHTML = renderRoleOptions("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetEquipmentForm() {
|
||||||
|
dom.equipmentForm.reset();
|
||||||
|
dom.equipmentId.value = "";
|
||||||
|
renderEquipmentUnitOptions("");
|
||||||
|
dom.equipmentKind.innerHTML = renderEquipmentKindOptions("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEquipmentModal() {
|
||||||
|
dom.equipmentModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeEquipmentModal() {
|
||||||
|
dom.equipmentModal.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCreateEquipmentModal() {
|
||||||
|
resetEquipmentForm();
|
||||||
|
if (state.selectedUnitId && dom.equipmentUnitId) {
|
||||||
|
dom.equipmentUnitId.value = state.selectedUnitId;
|
||||||
|
}
|
||||||
|
openEquipmentModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditEquipmentModal(equipment) {
|
||||||
|
dom.equipmentId.value = equipment.id || "";
|
||||||
|
dom.equipmentUnitId.value = equipment.unit_id || "";
|
||||||
|
dom.equipmentCode.value = equipment.code || "";
|
||||||
|
dom.equipmentName.value = equipment.name || "";
|
||||||
|
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(equipment.kind || "");
|
||||||
|
dom.equipmentDescription.value = equipment.description || "";
|
||||||
|
openEquipmentModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectEquipment(equipmentId) {
|
||||||
|
state.selectedEquipmentId = state.selectedEquipmentId === equipmentId ? null : equipmentId;
|
||||||
|
state.pointsPage = 1;
|
||||||
|
clearSelectedPoints();
|
||||||
|
renderEquipments();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearEquipmentFilter() {
|
||||||
|
state.selectedEquipmentId = null;
|
||||||
|
state.pointsPage = 1;
|
||||||
|
renderEquipments();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
return loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEquipments() {
|
||||||
|
dom.equipmentList.innerHTML = "";
|
||||||
|
|
||||||
|
const items = filteredEquipments();
|
||||||
|
if (!items.length) {
|
||||||
|
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">暂无设备</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${equipment.code}</strong>
|
||||||
|
<span class="badge">${item.point_count ?? 0} pts</span>
|
||||||
|
</div>
|
||||||
|
<div>${equipment.name}</div>
|
||||||
|
<div class="muted">${equipment.kind || "未分类"}</div>
|
||||||
|
<div class="muted">单元: ${currentUnitLabel(equipment.unit_id)}</div>
|
||||||
|
<div class="row equipment-card-actions"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.addEventListener("click", () => {
|
||||||
|
selectEquipment(equipment.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionRow = box.querySelector(".equipment-card-actions");
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "secondary";
|
||||||
|
editBtn.textContent = "编辑";
|
||||||
|
editBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openEditEquipmentModal(equipment);
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actionRow.append(editBtn, deleteBtn);
|
||||||
|
|
||||||
|
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];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
|
||||||
|
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(dom.equipmentKind?.value || "");
|
||||||
|
renderBindingEquipmentOptions();
|
||||||
|
renderBatchBindingDefaults();
|
||||||
|
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
|
||||||
|
state.selectedEquipmentId = null;
|
||||||
|
}
|
||||||
|
renderEquipments();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
document.dispatchEvent(new Event("equipments-updated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEquipment(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const unitId = dom.equipmentUnitId.value || null;
|
||||||
|
const payload = {
|
||||||
|
unit_id: unitId,
|
||||||
|
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;
|
||||||
|
const result = await apiFetch(id ? `/api/equipment/${id}` : "/api/equipment", {
|
||||||
|
method: id ? "PUT" : "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
closeEquipmentModal();
|
||||||
|
await loadEquipments();
|
||||||
|
if (!id && result?.id) {
|
||||||
|
state.selectedEquipmentId = result.id;
|
||||||
|
}
|
||||||
|
renderEquipments();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEquipment(equipmentId) {
|
||||||
|
if (!window.confirm("确认删除该设备?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/equipment/${equipmentId}`, { method: "DELETE" });
|
||||||
|
if (state.selectedEquipmentId === equipmentId) {
|
||||||
|
state.selectedEquipmentId = null;
|
||||||
|
}
|
||||||
|
resetEquipmentForm();
|
||||||
|
closeEquipmentModal();
|
||||||
|
clearSelectedPoints();
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPointBinding(pointId = dom.bindingPointId.value) {
|
||||||
|
await apiFetch(`/api/point/${pointId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ equipment_id: null, signal_role: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.pointBindingModal.classList.add("hidden");
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
let _page = 1;
|
||||||
|
let _hasMore = false;
|
||||||
|
let _loading = false;
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
return value || "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCard(item) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const level = (item.level || "info").toLowerCase();
|
||||||
|
row.className = "event-card";
|
||||||
|
row.innerHTML = `<span class="badge event-badge level-${level}">${level.toUpperCase()}</span><span class="muted event-time">${formatTime(item.created_at)}</span><span class="event-type">${item.event_type}</span><span class="event-message">${item.message}</span>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (_loading || !_hasMore) return;
|
||||||
|
_loading = true;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ page: String(_page), page_size: String(PAGE_SIZE) });
|
||||||
|
if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/api/event?${params.toString()}`);
|
||||||
|
const items = response.data || [];
|
||||||
|
items.forEach((item) => dom.eventList.appendChild(makeCard(item)));
|
||||||
|
_hasMore = items.length === PAGE_SIZE;
|
||||||
|
_page += 1;
|
||||||
|
} finally {
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEvents() {
|
||||||
|
_page = 1;
|
||||||
|
_hasMore = false;
|
||||||
|
_loading = false;
|
||||||
|
dom.eventList.innerHTML = "";
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ page: "1", page_size: String(PAGE_SIZE) });
|
||||||
|
if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId);
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/api/event?${params.toString()}`);
|
||||||
|
const items = response.data || [];
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
dom.eventList.innerHTML = '<div class="list-item"><div class="muted">暂无事件</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => dom.eventList.appendChild(makeCard(item)));
|
||||||
|
_hasMore = items.length === PAGE_SIZE;
|
||||||
|
_page = 2;
|
||||||
|
} finally {
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependEvent(item) {
|
||||||
|
if (state.selectedUnitId && item.unit_id !== state.selectedUnitId) return;
|
||||||
|
|
||||||
|
const placeholder = dom.eventList.querySelector(".list-item");
|
||||||
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
|
dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild);
|
||||||
|
|
||||||
|
// Keep DOM bounded to prevent unbounded growth
|
||||||
|
const cards = dom.eventList.querySelectorAll(".event-card");
|
||||||
|
if (cards.length > 100) cards[cards.length - 1].remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.eventList.addEventListener("scroll", () => {
|
||||||
|
const el = dom.eventList;
|
||||||
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
async function loadPartial(slot) {
|
||||||
|
const response = await fetch(slot.dataset.partial);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load partial: ${slot.dataset.partial}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
slot.insertAdjacentHTML("beforebegin", html);
|
||||||
|
slot.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapPage() {
|
||||||
|
const slots = Array.from(document.querySelectorAll("[data-partial]"));
|
||||||
|
await Promise.all(slots.map((slot) => loadPartial(slot)));
|
||||||
|
await import("./app.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapPage().catch((error) => {
|
||||||
|
document.body.innerHTML = `<pre>${error.message || String(error)}</pre>`;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { appendChartPoint } from "./chart.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { prependEvent } from "./events.js";
|
||||||
|
import { formatValue } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { loadUnits, renderUnits } from "./units.js";
|
||||||
|
import { loadEquipments } from "./equipment.js";
|
||||||
|
import { showToast } from "./api.js";
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLine(line) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||||
|
try { return JSON.parse(trimmed); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(line) {
|
||||||
|
if (!dom.logView) return;
|
||||||
|
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
const parsed = parseLogLine(line);
|
||||||
|
if (!parsed) {
|
||||||
|
div.className = "log-line";
|
||||||
|
div.textContent = line;
|
||||||
|
} else {
|
||||||
|
const levelRaw = (parsed.level || "").toString();
|
||||||
|
const level = levelRaw.toLowerCase();
|
||||||
|
div.className = `log-line${level ? ` level-${level}` : ""}`;
|
||||||
|
div.innerHTML = [
|
||||||
|
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||||||
|
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||||||
|
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
|
||||||
|
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
dom.logView.appendChild(div);
|
||||||
|
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLogDivider(text) {
|
||||||
|
if (!dom.logView) return;
|
||||||
|
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "log-line muted";
|
||||||
|
div.textContent = text;
|
||||||
|
dom.logView.appendChild(div);
|
||||||
|
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLogs() {
|
||||||
|
if (state.logSource) return;
|
||||||
|
let currentLogFile = null;
|
||||||
|
state.logSource = new EventSource("/api/logs/stream");
|
||||||
|
state.logSource.addEventListener("log", (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.reset && data.file && data.file !== currentLogFile) {
|
||||||
|
appendLogDivider(`[log switched to ${data.file}]`);
|
||||||
|
}
|
||||||
|
currentLogFile = data.file || currentLogFile;
|
||||||
|
(data.lines || []).forEach(appendLog);
|
||||||
|
});
|
||||||
|
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopLogs() {
|
||||||
|
if (state.logSource) {
|
||||||
|
state.logSource.close();
|
||||||
|
state.logSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _disconnectToast = null;
|
||||||
|
|
||||||
|
function setWsStatus(connected) {
|
||||||
|
if (dom.wsDot) {
|
||||||
|
dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`;
|
||||||
|
}
|
||||||
|
if (dom.wsLabel) {
|
||||||
|
dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…";
|
||||||
|
}
|
||||||
|
if (!connected && !_disconnectToast) {
|
||||||
|
_disconnectToast = showToast("后端连接断开", {
|
||||||
|
message: "正在重连,请稍候…",
|
||||||
|
level: "error",
|
||||||
|
duration: 0,
|
||||||
|
shake: true,
|
||||||
|
});
|
||||||
|
} else if (connected && _disconnectToast) {
|
||||||
|
_disconnectToast.dismiss();
|
||||||
|
_disconnectToast = null;
|
||||||
|
showToast("连接已恢复", { level: "success", duration: 3000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _reconnectDelay = 1000;
|
||||||
|
let _connectedOnce = false;
|
||||||
|
|
||||||
|
export function startPointSocket() {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
||||||
|
state.pointSocket = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setWsStatus(true);
|
||||||
|
_reconnectDelay = 1000;
|
||||||
|
if (_connectedOnce) {
|
||||||
|
loadUnits().catch(() => {});
|
||||||
|
if (state.activeView === "config") loadEquipments().catch(() => {});
|
||||||
|
}
|
||||||
|
_connectedOnce = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
|
||||||
|
const data = payload.data;
|
||||||
|
|
||||||
|
// config view point table
|
||||||
|
const entry = state.pointEls.get(data.point_id);
|
||||||
|
if (entry) {
|
||||||
|
entry.value.textContent = formatValue(data);
|
||||||
|
entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
|
||||||
|
entry.quality.textContent = (data.quality || "unknown").toUpperCase();
|
||||||
|
entry.time.textContent = data.timestamp || "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ops view signal pill
|
||||||
|
const opsEntry = state.opsPointEls.get(data.point_id);
|
||||||
|
if (opsEntry) {
|
||||||
|
const { pillEl, syncBtns } = opsEntry;
|
||||||
|
state.opsSignalCache.set(data.point_id, { quality: data.quality, value_text: data.value_text });
|
||||||
|
const role = pillEl.dataset.opsRole;
|
||||||
|
import("./ops.js").then(({ sigPillClass }) => {
|
||||||
|
pillEl.className = sigPillClass(role, data.quality, data.value_text);
|
||||||
|
syncBtns?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.chartPointId === data.point_id) {
|
||||||
|
appendChartPoint(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "EventCreated" || payload.type === "event_created") {
|
||||||
|
prependEvent(payload.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "UnitRuntimeChanged") {
|
||||||
|
const runtime = payload.data;
|
||||||
|
state.runtimes.set(runtime.unit_id, runtime);
|
||||||
|
renderUnits();
|
||||||
|
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
|
||||||
|
import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => {
|
||||||
|
renderOpsUnits();
|
||||||
|
syncEquipmentButtonsForUnit(runtime.unit_id);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setWsStatus(false);
|
||||||
|
window.setTimeout(startPointSocket, _reconnectDelay);
|
||||||
|
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => setWsStatus(false);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { loadUnits } from "./units.js";
|
||||||
|
|
||||||
|
const SIGNAL_ROLES = ["rem", "run", "flt"];
|
||||||
|
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
|
||||||
|
|
||||||
|
function isSignalOn(quality, valueText) {
|
||||||
|
if (!quality || quality.toLowerCase() !== "good") return false;
|
||||||
|
const v = String(valueText ?? "").trim().toLowerCase();
|
||||||
|
return v === "1" || v === "true" || v === "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sigPillClass(role, quality, valueText) {
|
||||||
|
if (!quality || quality.toLowerCase() !== "good") return "sig-pill sig-warn";
|
||||||
|
const on = isSignalOn(quality, valueText);
|
||||||
|
if (!on) return "sig-pill";
|
||||||
|
return role === "flt" ? "sig-pill sig-fault" : "sig-pill sig-on";
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeBadge(runtime) {
|
||||||
|
if (!runtime) return '<span class="badge offline">OFFLINE</span>';
|
||||||
|
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
|
||||||
|
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
|
||||||
|
const labels = { stopped: "STOPPED", running: "RUNNING", distributor_running: "DIST RUN", fault_locked: "FAULT", comm_locked: "COMM ERR" };
|
||||||
|
const cls = { stopped: "", running: "online", distributor_running: "online", fault_locked: "danger", comm_locked: "offline" };
|
||||||
|
return `<span class="badge ${cls[runtime.state] ?? ""}">${labels[runtime.state] ?? runtime.state}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOpsUnits() {
|
||||||
|
if (!dom.opsUnitList) return;
|
||||||
|
dom.opsUnitList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.units.length) {
|
||||||
|
dom.opsUnitList.innerHTML = '<div class="muted" style="padding:12px">暂无控制单元</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const runtime = state.runtimes.get(unit.id);
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`;
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="ops-unit-item-name">${unit.code} / ${unit.name}</div>
|
||||||
|
<div class="ops-unit-item-meta">
|
||||||
|
${runtimeBadge(runtime)}
|
||||||
|
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
||||||
|
${runtime ? `<span class="muted">Acc ${Math.floor(runtime.display_acc_sec / 1000)}s</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="ops-unit-item-actions"></div>
|
||||||
|
`;
|
||||||
|
item.addEventListener("click", () => selectOpsUnit(unit.id));
|
||||||
|
|
||||||
|
const actions = item.querySelector(".ops-unit-item-actions");
|
||||||
|
|
||||||
|
const isAutoOn = runtime?.auto_enabled;
|
||||||
|
const startBlocked = !isAutoOn && (runtime?.fault_locked || runtime?.manual_ack_required || runtime?.rem_local);
|
||||||
|
const autoBtn = document.createElement("button");
|
||||||
|
autoBtn.className = isAutoOn ? "danger" : "secondary";
|
||||||
|
autoBtn.textContent = isAutoOn ? "停止自动" : "启动自动";
|
||||||
|
autoBtn.disabled = startBlocked;
|
||||||
|
autoBtn.title = startBlocked
|
||||||
|
? (runtime?.fault_locked ? "设备故障中,无法启动自动控制"
|
||||||
|
: runtime?.rem_local ? "设备处于本地模式(REM关),无法启动自动控制"
|
||||||
|
: "需人工确认故障后才可启动自动控制")
|
||||||
|
: (isAutoOn ? "停止自动控制" : "启动自动控制");
|
||||||
|
autoBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
apiFetch(`/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`, { method: "POST" })
|
||||||
|
.then(() => loadUnits()).catch(() => {});
|
||||||
|
});
|
||||||
|
actions.append(autoBtn);
|
||||||
|
|
||||||
|
if (runtime?.manual_ack_required) {
|
||||||
|
const ackBtn = document.createElement("button");
|
||||||
|
ackBtn.className = "danger";
|
||||||
|
ackBtn.textContent = "故障确认";
|
||||||
|
ackBtn.title = "人工确认解除故障锁定";
|
||||||
|
ackBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
apiFetch(`/api/control/unit/${unit.id}/ack-fault`, { method: "POST" })
|
||||||
|
.then(() => loadUnits()).catch(() => {});
|
||||||
|
});
|
||||||
|
actions.append(ackBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.opsUnitList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOpsUnit(unitId) {
|
||||||
|
state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId;
|
||||||
|
renderOpsUnits();
|
||||||
|
state.opsPointEls.clear();
|
||||||
|
|
||||||
|
if (!state.selectedOpsUnitId) {
|
||||||
|
renderOpsEquipments(state.units.flatMap((u) => u.equipments || []));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = state.unitMap.get(unitId);
|
||||||
|
renderOpsEquipments(unit ? (unit.equipments || []) : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAllEquipmentCards() {
|
||||||
|
if (!dom.opsEquipmentArea) return;
|
||||||
|
state.opsPointEls.clear();
|
||||||
|
renderOpsEquipments(state.units.flatMap((u) => u.equipments || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOpsEquipments(equipments) {
|
||||||
|
dom.opsEquipmentArea.innerHTML = "";
|
||||||
|
state.opsUnitSyncFns.clear();
|
||||||
|
|
||||||
|
if (!equipments.length) {
|
||||||
|
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
equipments.forEach((eq) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "ops-eq-card";
|
||||||
|
|
||||||
|
const roleMap = {};
|
||||||
|
(eq.role_points || []).forEach((p) => { roleMap[p.signal_role] = p; });
|
||||||
|
|
||||||
|
// Signal pills — one pill per bound role, text label inside
|
||||||
|
const signalRowsHtml = SIGNAL_ROLES.map((role) => {
|
||||||
|
const point = roleMap[role];
|
||||||
|
if (!point) return "";
|
||||||
|
return `<span class="sig-pill sig-warn" data-ops-dot="${point.point_id}" data-ops-role="${role}">${ROLE_LABELS[role] || role}</span>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
|
||||||
|
const unitId = eq.unit_id ?? null;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="ops-eq-card-head">
|
||||||
|
<strong title="${eq.name}">${eq.code}</strong>
|
||||||
|
<span class="badge">${eq.kind || "--"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div>
|
||||||
|
${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${unitId || ""}"></div>` : ""}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let syncBtns = null;
|
||||||
|
|
||||||
|
if (canControl) {
|
||||||
|
const actions = card.querySelector(".ops-eq-card-actions");
|
||||||
|
const remPointId = roleMap["rem"]?.point_id ?? null;
|
||||||
|
const fltPointId = roleMap["flt"]?.point_id ?? null;
|
||||||
|
|
||||||
|
const startBtn = document.createElement("button");
|
||||||
|
startBtn.className = "secondary";
|
||||||
|
startBtn.textContent = "启动";
|
||||||
|
startBtn.addEventListener("click", () =>
|
||||||
|
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
|
||||||
|
);
|
||||||
|
const stopBtn = document.createElement("button");
|
||||||
|
stopBtn.className = "danger";
|
||||||
|
stopBtn.textContent = "停止";
|
||||||
|
stopBtn.addEventListener("click", () =>
|
||||||
|
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
|
||||||
|
);
|
||||||
|
actions.append(startBtn, stopBtn);
|
||||||
|
|
||||||
|
syncBtns = function () {
|
||||||
|
const autoOn = !!(unitId && state.runtimes.get(unitId)?.auto_enabled);
|
||||||
|
const remSig = remPointId ? state.opsSignalCache.get(remPointId) : null;
|
||||||
|
const fltSig = fltPointId ? state.opsSignalCache.get(fltPointId) : null;
|
||||||
|
const remOk = !remPointId || isSignalOn(remSig?.quality, remSig?.value_text);
|
||||||
|
const fltActive = !!(fltPointId && isSignalOn(fltSig?.quality, fltSig?.value_text));
|
||||||
|
const disabled = autoOn || !remOk || fltActive;
|
||||||
|
const title = autoOn ? "自动控制运行中,请先停止自动"
|
||||||
|
: !remOk ? "设备未切换至远程模式"
|
||||||
|
: fltActive ? "设备故障中"
|
||||||
|
: "";
|
||||||
|
startBtn.disabled = disabled;
|
||||||
|
stopBtn.disabled = disabled;
|
||||||
|
startBtn.title = title;
|
||||||
|
stopBtn.title = title;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.opsEquipmentArea.appendChild(card);
|
||||||
|
|
||||||
|
// Register pills for WS updates; seed signal cache from initial point_monitor data
|
||||||
|
SIGNAL_ROLES.forEach((role) => {
|
||||||
|
const point = roleMap[role];
|
||||||
|
if (!point) return;
|
||||||
|
const pillEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`);
|
||||||
|
if (!pillEl) return;
|
||||||
|
if (point.point_monitor) {
|
||||||
|
const m = point.point_monitor;
|
||||||
|
state.opsSignalCache.set(point.point_id, { quality: m.quality, value_text: m.value_text });
|
||||||
|
pillEl.className = sigPillClass(role, m.quality, m.value_text);
|
||||||
|
}
|
||||||
|
const isSyncRole = canControl && (role === "rem" || role === "flt");
|
||||||
|
state.opsPointEls.set(point.point_id, { pillEl, syncBtns: isSyncRole ? syncBtns : null });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canControl) {
|
||||||
|
syncBtns();
|
||||||
|
if (unitId) {
|
||||||
|
if (!state.opsUnitSyncFns.has(unitId)) state.opsUnitSyncFns.set(unitId, new Set());
|
||||||
|
state.opsUnitSyncFns.get(unitId).add(syncBtns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOps() {
|
||||||
|
renderOpsUnits();
|
||||||
|
|
||||||
|
dom.batchStartAutoBtn?.addEventListener("click", () => {
|
||||||
|
apiFetch("/api/control/unit/batch-start-auto", { method: "POST" })
|
||||||
|
.then(() => loadUnits())
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.batchStopAutoBtn?.addEventListener("click", () => {
|
||||||
|
apiFetch("/api/control/unit/batch-stop-auto", { method: "POST" })
|
||||||
|
.then(() => loadUnits())
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by WS handler when a unit's runtime changes — re-evaluates all equipment button states. */
|
||||||
|
export function syncEquipmentButtonsForUnit(unitId) {
|
||||||
|
state.opsUnitSyncFns.get(unitId)?.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { openChart } from "./chart.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import {
|
||||||
|
loadEquipments,
|
||||||
|
renderBatchBindingDefaults,
|
||||||
|
renderBindingEquipmentOptions,
|
||||||
|
} from "./equipment.js";
|
||||||
|
import { renderRoleOptions } from "./roles.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function updatePointSourceNodeCount() {
|
||||||
|
const count = dom.nodeTree.querySelectorAll("details").length;
|
||||||
|
dom.pointSourceNodeCount.textContent = `节点: ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 个节点`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSelectedPointSummary() {
|
||||||
|
const count = state.selectedPointIds.size;
|
||||||
|
dom.selectedPointCount.textContent = `已选中 ${count} 个点位`;
|
||||||
|
dom.batchBindingSummary.textContent = `已选中 ${count} 个点位`;
|
||||||
|
dom.openBatchBindingBtn.disabled = count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePointFilterSummary() {
|
||||||
|
const filters = [];
|
||||||
|
if (state.selectedEquipmentId) {
|
||||||
|
const equipment = state.equipmentMap.get(state.selectedEquipmentId);
|
||||||
|
filters.push(`设备:${equipment?.name || equipment?.code || "未知"}`);
|
||||||
|
}
|
||||||
|
if (state.selectedSourceId) {
|
||||||
|
const source = state.sources.find((item) => item.id === state.selectedSourceId);
|
||||||
|
filters.push(`数据源:${source?.name || "未知"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.pointFilterSummary.textContent = filters.length
|
||||||
|
? `当前筛选: ${filters.join(" / ")}`
|
||||||
|
: "当前筛选: 全部点位";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelectedPoints() {
|
||||||
|
state.selectedPointIds.clear();
|
||||||
|
dom.toggleAllPoints.checked = false;
|
||||||
|
dom.pointList
|
||||||
|
.querySelectorAll('input[data-point-select="true"]')
|
||||||
|
.forEach((input) => (input.checked = false));
|
||||||
|
updateSelectedPointSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node) {
|
||||||
|
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 function openPointCreateModal() {
|
||||||
|
dom.pointModal.classList.remove("hidden");
|
||||||
|
if (dom.pointSourceSelect) {
|
||||||
|
dom.pointSourceSelect.value = state.selectedSourceId || "";
|
||||||
|
}
|
||||||
|
dom.nodeTree.innerHTML = '<div class="muted">选择数据源并加载节点</div>';
|
||||||
|
dom.pointSourceNodeCount.textContent = "节点: 0";
|
||||||
|
state.selectedNodeIds.clear();
|
||||||
|
renderSelectedNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTree() {
|
||||||
|
const sourceId = dom.pointSourceSelect.value || state.selectedSourceId;
|
||||||
|
if (!sourceId) {
|
||||||
|
dom.nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
|
||||||
|
dom.pointSourceNodeCount.textContent = "节点: 0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedSourceId = sourceId;
|
||||||
|
const data = await apiFetch(`/api/source/${sourceId}/node-tree`);
|
||||||
|
dom.nodeTree.innerHTML = "";
|
||||||
|
(data || []).forEach((node) => dom.nodeTree.appendChild(renderNode(node)));
|
||||||
|
updatePointSourceNodeCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browseAndLoadTree() {
|
||||||
|
const sourceId = dom.pointSourceSelect.value || state.selectedSourceId;
|
||||||
|
if (!sourceId) {
|
||||||
|
throw new Error("请先选择数据源");
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedSourceId = sourceId;
|
||||||
|
await apiFetch(`/api/source/${sourceId}/browse`, { method: "POST" });
|
||||||
|
await loadTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPointSelected(pointId, checked) {
|
||||||
|
if (checked) {
|
||||||
|
state.selectedPointIds.add(pointId);
|
||||||
|
} else {
|
||||||
|
state.selectedPointIds.delete(pointId);
|
||||||
|
}
|
||||||
|
updateSelectedPointSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPoints() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(state.pointsPage),
|
||||||
|
page_size: String(state.pointsPageSize),
|
||||||
|
});
|
||||||
|
if (state.selectedSourceId) {
|
||||||
|
params.set("source_id", state.selectedSourceId);
|
||||||
|
}
|
||||||
|
if (state.selectedEquipmentId) {
|
||||||
|
params.set("equipment_id", state.selectedEquipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await apiFetch(`/api/point?${params.toString()}`);
|
||||||
|
const items = data.data || [];
|
||||||
|
state.pointsTotal = typeof data.total === "number" ? data.total : items.length;
|
||||||
|
state.pointEls.clear();
|
||||||
|
dom.pointList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
dom.pointList.innerHTML = '<tr><td colspan="7" class="empty-state">暂无点位</td></tr>';
|
||||||
|
dom.pointsPageInfo.textContent = `${state.pointsPage} / 1`;
|
||||||
|
clearSelectedPoints();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const point = item.point || item;
|
||||||
|
const monitor = item.point_monitor || null;
|
||||||
|
const equipment = point.equipment_id ? state.equipmentMap.get(point.equipment_id) : null;
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
tr.addEventListener("click", () => {
|
||||||
|
openChart(point.id, point.name).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<div class="point-name">${point.name}</div>
|
||||||
|
<div class="point-id">${point.node_id}</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="point-value">${formatValue(monitor)}</span></td>
|
||||||
|
<td><span class="badge quality-${(monitor?.quality || "unknown").toLowerCase()}">${(monitor?.quality || "unknown").toUpperCase()}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="point-meta">
|
||||||
|
<div>${equipment ? equipment.name : '<span class="muted">未绑定</span>'}</div>
|
||||||
|
<div class="point-role">${point.signal_role || "--"}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="muted">${monitor?.timestamp || "--"}</span></td>
|
||||||
|
<td></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const selectCell = tr.children[0];
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.dataset.pointSelect = "true";
|
||||||
|
checkbox.checked = state.selectedPointIds.has(point.id);
|
||||||
|
checkbox.addEventListener("click", (event) => event.stopPropagation());
|
||||||
|
checkbox.addEventListener("change", () => setPointSelected(point.id, checkbox.checked));
|
||||||
|
selectCell.appendChild(checkbox);
|
||||||
|
|
||||||
|
const actionCell = tr.lastElementChild;
|
||||||
|
actionCell.className = "point-actions";
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "secondary";
|
||||||
|
editBtn.textContent = "编辑";
|
||||||
|
editBtn.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(editBtn, 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(6) .muted"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||||
|
dom.pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`;
|
||||||
|
const pageCheckboxes = dom.pointList.querySelectorAll('input[data-point-select="true"]');
|
||||||
|
dom.toggleAllPoints.checked =
|
||||||
|
pageCheckboxes.length > 0 && Array.from(pageCheckboxes).every((input) => input.checked);
|
||||||
|
updateSelectedPointSummary();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPointBinding(point) {
|
||||||
|
dom.bindingPointId.value = point.id;
|
||||||
|
dom.bindingPointName.value = point.name || "";
|
||||||
|
dom.bindingPointName.disabled = false;
|
||||||
|
const modalTitle = dom.pointBindingModal.querySelector("h3");
|
||||||
|
if (modalTitle) {
|
||||||
|
modalTitle.textContent = "编辑点位";
|
||||||
|
}
|
||||||
|
if (dom.clearPointBindingBtn) {
|
||||||
|
dom.clearPointBindingBtn.textContent = "清除设备";
|
||||||
|
}
|
||||||
|
const saveButton = dom.pointBindingForm?.querySelector('button[type="submit"]');
|
||||||
|
if (saveButton) {
|
||||||
|
saveButton.textContent = "保存";
|
||||||
|
}
|
||||||
|
renderBindingEquipmentOptions(point.equipment_id || "");
|
||||||
|
dom.bindingSignalRole.innerHTML = renderRoleOptions(point.signal_role || "");
|
||||||
|
dom.pointBindingModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePointBinding(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
await apiFetch(`/api/point/${dom.bindingPointId.value}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: dom.bindingPointName.value.trim() || null,
|
||||||
|
equipment_id: dom.bindingEquipmentId.value || null,
|
||||||
|
signal_role: dom.bindingSignalRole.value || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.pointBindingModal.classList.add("hidden");
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openBatchBinding() {
|
||||||
|
if (!state.selectedPointIds.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderBatchBindingDefaults();
|
||||||
|
updateSelectedPointSummary();
|
||||||
|
dom.batchBindingModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBatchBinding(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!state.selectedPointIds.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch("/api/point/batch/set-equipment", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
point_ids: Array.from(state.selectedPointIds),
|
||||||
|
equipment_id: dom.batchBindingEquipmentId.value || null,
|
||||||
|
signal_role: dom.batchBindingSignalRole.value || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.batchBindingModal.classList.add("hidden");
|
||||||
|
clearSelectedPoints();
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearBatchBinding() {
|
||||||
|
if (!state.selectedPointIds.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch("/api/point/batch/set-equipment", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
point_ids: Array.from(state.selectedPointIds),
|
||||||
|
equipment_id: null,
|
||||||
|
signal_role: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.batchBindingModal.classList.add("hidden");
|
||||||
|
clearSelectedPoints();
|
||||||
|
await loadEquipments();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePoint(pointId) {
|
||||||
|
if (!window.confirm("确认删除该点位?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/point/${pointId}`, { method: "DELETE" });
|
||||||
|
state.selectedPointIds.delete(pointId);
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
export const SIGNAL_ROLE_OPTIONS = [
|
||||||
|
{ value: "", label: "未设置" },
|
||||||
|
{ value: "rem", label: "REM 远程使能" },
|
||||||
|
{ value: "run", label: "RUN 运行" },
|
||||||
|
{ value: "flt", label: "FLT 故障" },
|
||||||
|
{ value: "ii", label: "II 电流" },
|
||||||
|
{ value: "start_cmd", label: "启动命令" },
|
||||||
|
{ value: "stop_cmd", label: "停止命令" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EQUIPMENT_KIND_OPTIONS = [
|
||||||
|
{ value: "", label: "未设置" },
|
||||||
|
{ value: "coal_feeder", label: "投煤器" },
|
||||||
|
{ value: "distributor", label: "布料机" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function renderRoleOptions(selected = "") {
|
||||||
|
return SIGNAL_ROLE_OPTIONS.map((item) => {
|
||||||
|
const isSelected = item.value === selected ? "selected" : "";
|
||||||
|
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEquipmentKindOptions(selected = "") {
|
||||||
|
return EQUIPMENT_KIND_OPTIONS.map((item) => {
|
||||||
|
const isSelected = item.value === selected ? "selected" : "";
|
||||||
|
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { loadPoints, updatePointFilterSummary } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function renderPointSourceOptions() {
|
||||||
|
if (!dom.pointSourceSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ['<option value="">选择数据源</option>'];
|
||||||
|
state.sources.forEach((source) => {
|
||||||
|
const selected = source.id === state.selectedSourceId ? "selected" : "";
|
||||||
|
options.push(`<option value="${source.id}" ${selected}>${source.name}</option>`);
|
||||||
|
});
|
||||||
|
dom.pointSourceSelect.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSources() {
|
||||||
|
dom.sourceList.innerHTML = "";
|
||||||
|
|
||||||
|
state.sources.forEach((source) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = `list-item source-card ${state.selectedSourceId === source.id ? "selected" : ""}`;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${source.name}</strong>
|
||||||
|
<span class="badge ${source.is_connected ? "" : "offline"}">${source.is_connected ? "ONLINE" : "OFFLINE"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted">${source.endpoint}</div>
|
||||||
|
<div class="row source-card-actions"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
selectSource(source.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionRow = card.querySelector(".source-card-actions");
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "secondary";
|
||||||
|
editBtn.textContent = "编辑";
|
||||||
|
editBtn.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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actionRow.append(editBtn, reconnectBtn, deleteBtn);
|
||||||
|
card.appendChild(actionRow);
|
||||||
|
dom.sourceList.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPointSourceOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSources() {
|
||||||
|
state.sources = await apiFetch("/api/source");
|
||||||
|
if (state.selectedSourceId && !state.sources.some((item) => item.id === state.selectedSourceId)) {
|
||||||
|
state.selectedSourceId = null;
|
||||||
|
}
|
||||||
|
renderSources();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectSource(sourceId) {
|
||||||
|
state.selectedSourceId = state.selectedSourceId === sourceId ? null : sourceId;
|
||||||
|
state.selectedNodeIds.clear();
|
||||||
|
state.pointsPage = 1;
|
||||||
|
renderSources();
|
||||||
|
updatePointFilterSummary();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 reconnectSource(sourceId, name) {
|
||||||
|
dom.statusText.textContent = `正在重连 ${name || "数据源"}...`;
|
||||||
|
await apiFetch(`/api/source/${sourceId}/reconnect`, { method: "POST" });
|
||||||
|
await loadSources();
|
||||||
|
dom.statusText.textContent = "就绪";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSource(sourceId) {
|
||||||
|
if (!window.confirm("确认删除该数据源?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/source/${sourceId}`, { method: "DELETE" });
|
||||||
|
if (state.selectedSourceId === sourceId) {
|
||||||
|
state.selectedSourceId = null;
|
||||||
|
}
|
||||||
|
await loadSources();
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
export const state = {
|
||||||
|
units: [],
|
||||||
|
unitMap: new Map(),
|
||||||
|
selectedUnitId: null,
|
||||||
|
sources: [],
|
||||||
|
events: [],
|
||||||
|
equipments: [],
|
||||||
|
equipmentMap: new Map(),
|
||||||
|
selectedEquipmentId: null,
|
||||||
|
selectedSourceId: null,
|
||||||
|
selectedNodeIds: new Set(),
|
||||||
|
selectedPointIds: new Set(),
|
||||||
|
pointsPage: 1,
|
||||||
|
pointsPageSize: 100,
|
||||||
|
pointsTotal: 0,
|
||||||
|
pointEls: new Map(),
|
||||||
|
chartPointId: null,
|
||||||
|
chartPointName: "",
|
||||||
|
chartData: [],
|
||||||
|
pointSocket: null,
|
||||||
|
docDrawerSource: null, // null | "api" | "readme"
|
||||||
|
runtimes: new Map(), // unit_id -> UnitRuntime
|
||||||
|
activeView: "ops", // "ops" | "config"
|
||||||
|
opsPointEls: new Map(), // point_id -> { pillEl, syncBtns? }
|
||||||
|
opsSignalCache: new Map(), // point_id -> { quality, value_text }
|
||||||
|
opsUnitSyncFns: new Map(), // unit_id -> Set<syncBtns fn>
|
||||||
|
logSource: null,
|
||||||
|
selectedOpsUnitId: null,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
import { apiFetch, withStatus } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { loadEvents } from "./events.js";
|
||||||
|
import { loadEquipments, renderEquipments } from "./equipment.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function equipmentOf(item) {
|
||||||
|
return item && item.equipment ? item.equipment : item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function equipmentCount(unitId) {
|
||||||
|
return state.equipments.filter((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
return equipment.unit_id === unitId;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundEquipments(unitId) {
|
||||||
|
return state.equipments
|
||||||
|
.map(equipmentOf)
|
||||||
|
.filter((e) => e.unit_id === unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderUnitOptions(selected = "", target = dom.equipmentUnitId, includeEmpty = true) {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [];
|
||||||
|
if (includeEmpty) {
|
||||||
|
options.push('<option value="">未绑定单元</option>');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const isSelected = unit.id === selected ? "selected" : "";
|
||||||
|
options.push(`<option value="${unit.id}" ${isSelected}>${unit.code} / ${unit.name}</option>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
target.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetUnitForm() {
|
||||||
|
dom.unitForm.reset();
|
||||||
|
dom.unitId.value = "";
|
||||||
|
dom.unitEnabled.checked = true;
|
||||||
|
dom.unitManualAck.checked = true;
|
||||||
|
dom.unitRunTimeSec.value = "10";
|
||||||
|
dom.unitStopTimeSec.value = "10";
|
||||||
|
dom.unitAccTimeSec.value = "20";
|
||||||
|
dom.unitBlTimeSec.value = "10";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUnitModal() {
|
||||||
|
dom.unitModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeUnitModal() {
|
||||||
|
dom.unitModal.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCreateUnitModal() {
|
||||||
|
resetUnitForm();
|
||||||
|
openUnitModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditUnitModal(unit) {
|
||||||
|
dom.unitId.value = unit.id || "";
|
||||||
|
dom.unitCode.value = unit.code || "";
|
||||||
|
dom.unitName.value = unit.name || "";
|
||||||
|
dom.unitDescription.value = unit.description || "";
|
||||||
|
dom.unitEnabled.checked = !!unit.enabled;
|
||||||
|
dom.unitRunTimeSec.value = String(unit.run_time_sec ?? 0);
|
||||||
|
dom.unitStopTimeSec.value = String(unit.stop_time_sec ?? 0);
|
||||||
|
dom.unitAccTimeSec.value = String(unit.acc_time_sec ?? 0);
|
||||||
|
dom.unitBlTimeSec.value = String(unit.bl_time_sec ?? 0);
|
||||||
|
dom.unitManualAck.checked = !!unit.require_manual_ack_after_fault;
|
||||||
|
openUnitModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectUnit(unitId) {
|
||||||
|
state.selectedUnitId = state.selectedUnitId === unitId ? null : unitId;
|
||||||
|
renderUnits();
|
||||||
|
renderEquipments();
|
||||||
|
await loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeBadge(runtime) {
|
||||||
|
if (!runtime) return '<span class="badge offline">OFFLINE</span>';
|
||||||
|
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
|
||||||
|
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
|
||||||
|
const stateLabels = {
|
||||||
|
stopped: 'STOPPED',
|
||||||
|
running: 'RUNNING',
|
||||||
|
distributor_running: 'DIST RUN',
|
||||||
|
fault_locked: 'FAULT',
|
||||||
|
comm_locked: 'COMM ERR',
|
||||||
|
};
|
||||||
|
const stateCls = {
|
||||||
|
stopped: '',
|
||||||
|
running: 'online',
|
||||||
|
distributor_running: 'online',
|
||||||
|
fault_locked: 'danger',
|
||||||
|
comm_locked: 'offline',
|
||||||
|
};
|
||||||
|
const label = stateLabels[runtime.state] ?? runtime.state;
|
||||||
|
const cls = stateCls[runtime.state] ?? '';
|
||||||
|
return `<span class="badge ${cls}">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUnitCard(unit, mode) {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
const selected = mode === "interactive" && state.selectedUnitId === unit.id;
|
||||||
|
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
|
||||||
|
const runtime = state.runtimes.get(unit.id);
|
||||||
|
|
||||||
|
const bound = boundEquipments(unit.id);
|
||||||
|
const equipTags = bound.length
|
||||||
|
? bound.map((e) => `<span class="badge">${e.code}</span>`).join("")
|
||||||
|
: '<span class="muted">无设备</span>';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${unit.code}</strong>
|
||||||
|
${runtimeBadge(runtime)}
|
||||||
|
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
||||||
|
</div>
|
||||||
|
<div>${unit.name}</div>
|
||||||
|
<div class="muted">设备 ${bound.length} 台 | 累计 ${runtime ? Math.floor(runtime.display_acc_sec / 1000) : 0}s</div>
|
||||||
|
<div class="muted">运行 ${unit.run_time_sec}s / 停止 ${unit.stop_time_sec}s / 累计 ${unit.acc_time_sec}s / 间隔 ${unit.bl_time_sec}s</div>
|
||||||
|
${mode === "config" ? `<div class="unit-equipment-tags">${equipTags}</div>` : ""}
|
||||||
|
<div class="row unit-card-actions"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (mode === "interactive") {
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
selectUnit(unit.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = card.querySelector(".unit-card-actions");
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "secondary";
|
||||||
|
editBtn.textContent = "编辑";
|
||||||
|
editBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openEditUnitModal(unit);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "danger";
|
||||||
|
deleteBtn.textContent = "删除";
|
||||||
|
deleteBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deleteUnit(unit.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(editBtn, deleteBtn);
|
||||||
|
|
||||||
|
if (mode === "config") {
|
||||||
|
const selectEquipBtn = document.createElement("button");
|
||||||
|
selectEquipBtn.className = "secondary";
|
||||||
|
selectEquipBtn.textContent = "选择设备";
|
||||||
|
selectEquipBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openUnitEquipmentModal(unit);
|
||||||
|
});
|
||||||
|
actions.append(selectEquipBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToContainer(container, mode) {
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.units.length) {
|
||||||
|
container.innerHTML = '<div class="list-item"><div class="muted">暂无控制单元</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
container.appendChild(buildUnitCard(unit, mode));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderUnits() {
|
||||||
|
renderToContainer(dom.unitList, "interactive");
|
||||||
|
renderToContainer(dom.unitConfigList, "config");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUnits() {
|
||||||
|
const response = await apiFetch("/api/unit?page=1&page_size=-1");
|
||||||
|
state.units = response.data || [];
|
||||||
|
state.unitMap = new Map(state.units.map((unit) => [unit.id, unit]));
|
||||||
|
|
||||||
|
if (state.selectedUnitId && !state.unitMap.has(state.selectedUnitId)) {
|
||||||
|
state.selectedUnitId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
if (unit.runtime) state.runtimes.set(unit.id, unit.runtime);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderUnits();
|
||||||
|
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
|
||||||
|
document.dispatchEvent(new Event("units-loaded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUnit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code: dom.unitCode.value.trim(),
|
||||||
|
name: dom.unitName.value.trim(),
|
||||||
|
description: dom.unitDescription.value.trim() || null,
|
||||||
|
enabled: dom.unitEnabled.checked,
|
||||||
|
run_time_sec: Number(dom.unitRunTimeSec.value || 0),
|
||||||
|
stop_time_sec: Number(dom.unitStopTimeSec.value || 0),
|
||||||
|
acc_time_sec: Number(dom.unitAccTimeSec.value || 0),
|
||||||
|
bl_time_sec: Number(dom.unitBlTimeSec.value || 0),
|
||||||
|
require_manual_ack_after_fault: dom.unitManualAck.checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = dom.unitId.value;
|
||||||
|
await apiFetch(id ? `/api/unit/${id}` : "/api/unit", {
|
||||||
|
method: id ? "PUT" : "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
closeUnitModal();
|
||||||
|
await loadUnits();
|
||||||
|
renderEquipments();
|
||||||
|
await loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUnit(unitId) {
|
||||||
|
if (!window.confirm("确认删除该单元?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/unit/${unitId}`, { method: "DELETE" });
|
||||||
|
if (state.selectedUnitId === unitId) {
|
||||||
|
state.selectedUnitId = null;
|
||||||
|
}
|
||||||
|
closeUnitModal();
|
||||||
|
await loadUnits();
|
||||||
|
renderEquipments();
|
||||||
|
await loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unit Equipment Selection Modal ──
|
||||||
|
|
||||||
|
let _unitEquipmentTargetId = null;
|
||||||
|
const _unitEquipmentSelected = new Set();
|
||||||
|
|
||||||
|
function openUnitEquipmentModal(unit) {
|
||||||
|
_unitEquipmentTargetId = unit.id;
|
||||||
|
_unitEquipmentSelected.clear();
|
||||||
|
|
||||||
|
const allEquipments = state.equipments.map(equipmentOf);
|
||||||
|
const bound = new Set(boundEquipments(unit.id).map((e) => e.id));
|
||||||
|
bound.forEach((id) => _unitEquipmentSelected.add(id));
|
||||||
|
|
||||||
|
dom.unitEquipmentList.innerHTML = "";
|
||||||
|
dom.unitEquipmentList.className = "unit-equip-grid";
|
||||||
|
allEquipments.forEach((e) => {
|
||||||
|
const item = document.createElement("label");
|
||||||
|
item.className = "unit-equip-item";
|
||||||
|
const checked = bound.has(e.id) ? "checked" : "";
|
||||||
|
item.innerHTML = `<input type="checkbox" ${checked} /><span>${e.code}</span>`;
|
||||||
|
item.title = e.name;
|
||||||
|
item.querySelector("input").addEventListener("change", (ev) => {
|
||||||
|
if (ev.target.checked) _unitEquipmentSelected.add(e.id);
|
||||||
|
else _unitEquipmentSelected.delete(e.id);
|
||||||
|
});
|
||||||
|
dom.unitEquipmentList.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.unitEquipmentModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnitEquipmentModal() {
|
||||||
|
dom.unitEquipmentModal.classList.add("hidden");
|
||||||
|
_unitEquipmentTargetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmUnitEquipment() {
|
||||||
|
if (!_unitEquipmentTargetId) return;
|
||||||
|
|
||||||
|
const previouslyBound = new Set(boundEquipments(_unitEquipmentTargetId).map((e) => e.id));
|
||||||
|
|
||||||
|
const toBind = [..._unitEquipmentSelected].filter((id) => !previouslyBound.has(id));
|
||||||
|
const toUnbind = [...previouslyBound].filter((id) => !_unitEquipmentSelected.has(id));
|
||||||
|
|
||||||
|
if (toBind.length > 0) {
|
||||||
|
await apiFetch("/api/equipment/batch/set-unit", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ equipment_ids: toBind, unit_id: _unitEquipmentTargetId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toUnbind.length > 0) {
|
||||||
|
await apiFetch("/api/equipment/batch/set-unit", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ equipment_ids: toUnbind, unit_id: null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeUnitEquipmentModal();
|
||||||
|
await loadEquipments();
|
||||||
|
await loadUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindUnitEquipmentModalEvents() {
|
||||||
|
dom.closeUnitEquipmentModalBtn.addEventListener("click", closeUnitEquipmentModal);
|
||||||
|
dom.cancelUnitEquipmentBtn.addEventListener("click", closeUnitEquipmentModal);
|
||||||
|
dom.confirmUnitEquipmentBtn.addEventListener("click", () => withStatus(confirmUnitEquipment()));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="title">运转系统</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="status" id="statusText">
|
||||||
|
<span class="ws-dot" id="wsDot"></span>
|
||||||
|
<span id="wsLabel">连接中…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>运转系统</title>
|
||||||
|
<link rel="stylesheet" href="/ui/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div data-partial="/ui/html/topbar.html"></div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="muted" style="padding:2rem;text-align:center">运转系统页面开发中</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/ui/js/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
function bootstrap() {
|
||||||
|
console.log("Operation system app initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
async function loadPartial(slot) {
|
||||||
|
const response = await fetch(slot.dataset.partial);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load partial: ${slot.dataset.partial}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
slot.insertAdjacentHTML("beforebegin", html);
|
||||||
|
slot.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapPage() {
|
||||||
|
const slots = Array.from(document.querySelectorAll("[data-partial]"));
|
||||||
|
await Promise.all(slots.map((slot) => loadPartial(slot)));
|
||||||
|
await import("./app.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapPage().catch((error) => {
|
||||||
|
document.body.innerHTML = `<pre>${error.message || String(error)}</pre>`;
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue