feat(ops): embed role_points in equipment list, remove unit detail API calls

Equipment list response now includes signal-role points with monitor data,
so the ops view can render signal dots directly from state.equipments
without fetching /api/unit/:id/detail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-26 09:34:59 +08:00
parent 0e8d194a70
commit e304fd342d
5 changed files with 94 additions and 28 deletions

View File

@ -22,11 +22,19 @@ pub struct GetEquipmentListQuery {
pub pagination: PaginationParams, pub pagination: PaginationParams,
} }
#[derive(Serialize)]
pub struct SignalRolePoint {
pub point_id: uuid::Uuid,
pub signal_role: String,
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct EquipmentListItem { pub struct EquipmentListItem {
#[serde(flatten)] #[serde(flatten)]
pub equipment: crate::model::Equipment, pub equipment: crate::model::Equipment,
pub point_count: i64, pub point_count: i64,
pub role_points: Vec<SignalRolePoint>,
} }
pub async fn get_equipment_list( pub async fn get_equipment_list(
@ -36,7 +44,7 @@ pub async fn get_equipment_list(
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.pool, query.keyword.as_deref()).await?;
let data = crate::service::get_equipment_paginated( let items = crate::service::get_equipment_paginated(
&state.pool, &state.pool,
query.keyword.as_deref(), query.keyword.as_deref(),
query.pagination.page_size, query.pagination.page_size,
@ -44,6 +52,38 @@ pub async fn get_equipment_list(
) )
.await?; .await?;
let equipment_ids: Vec<uuid::Uuid> = items.iter().map(|item| item.equipment.id).collect();
let role_point_rows =
crate::service::get_signal_role_points_batch(&state.pool, &equipment_ids).await?;
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
let mut role_points_map: std::collections::HashMap<uuid::Uuid, Vec<SignalRolePoint>> =
std::collections::HashMap::new();
for rp in role_point_rows {
role_points_map
.entry(rp.equipment_id)
.or_default()
.push(SignalRolePoint {
point_id: rp.point_id,
signal_role: rp.signal_role,
point_monitor: monitor_guard.get(&rp.point_id).cloned(),
});
}
let data = items
.into_iter()
.map(|item| EquipmentListItem {
role_points: role_points_map
.remove(&item.equipment.id)
.unwrap_or_default(),
..item
})
.collect::<Vec<_>>();
Ok(Json(PaginatedResponse::new( Ok(Json(PaginatedResponse::new(
data, data,
total, total,

View File

@ -343,6 +343,43 @@ pub async fn get_points_by_equipment_ids(
.await .await
} }
pub struct EquipmentSignalRole {
pub equipment_id: Uuid,
pub point_id: Uuid,
pub signal_role: String,
}
/// Batch fetch all signal-role points for multiple equipment IDs in one query.
pub async fn get_signal_role_points_batch(
pool: &PgPool,
equipment_ids: &[Uuid],
) -> Result<Vec<EquipmentSignalRole>, sqlx::Error> {
if equipment_ids.is_empty() {
return Ok(vec![]);
}
let rows = sqlx::query(
r#"
SELECT p.equipment_id, p.id AS point_id, p.signal_role
FROM point p
WHERE p.equipment_id = ANY($1)
AND p.signal_role IS NOT NULL
ORDER BY p.equipment_id, p.created_at
"#,
)
.bind(equipment_ids)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| EquipmentSignalRole {
equipment_id: row.get("equipment_id"),
point_id: row.get("point_id"),
signal_role: row.get("signal_role"),
})
.collect())
}
pub async fn get_equipment_role_points( pub async fn get_equipment_role_points(
pool: &PgPool, pool: &PgPool,
equipment_id: Uuid, equipment_id: Uuid,

View File

@ -139,6 +139,7 @@ pub async fn get_equipment_paginated(
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
}, },
point_count: row.get::<i64, _>("point_count"), point_count: row.get::<i64, _>("point_count"),
role_points: vec![],
}) })
.collect()) .collect())
} }

View File

@ -162,11 +162,12 @@ function bindEvents() {
document.addEventListener("equipments-updated", () => { document.addEventListener("equipments-updated", () => {
renderUnits(); renderUnits();
renderOpsUnits(); renderOpsUnits();
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
}); });
document.addEventListener("units-loaded", () => { document.addEventListener("units-loaded", () => {
renderOpsUnits(); renderOpsUnits();
if (!state.selectedOpsUnitId) loadAllEquipmentCards(); if (state.equipments.length > 0 && !state.selectedOpsUnitId) loadAllEquipmentCards();
}); });
} }

View File

@ -78,37 +78,24 @@ export function renderOpsUnits() {
}); });
} }
async function selectOpsUnit(unitId) { function selectOpsUnit(unitId) {
state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId; state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId;
renderOpsUnits(); renderOpsUnits();
state.opsPointEls.clear();
if (!state.selectedOpsUnitId) { if (!state.selectedOpsUnitId) {
await loadAllEquipmentCards(); renderOpsEquipments(state.equipments);
return; return;
} }
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>'; const filtered = state.equipments.filter((eq) => eq.unit_id === unitId);
state.opsPointEls.clear(); renderOpsEquipments(filtered);
const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`);
renderOpsEquipments(detail.equipments || []);
} }
export async function loadAllEquipmentCards() { export function loadAllEquipmentCards() {
if (!dom.opsEquipmentArea) return; if (!dom.opsEquipmentArea) return;
if (!state.units.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">暂无控制单元</div>';
return;
}
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
state.opsPointEls.clear(); state.opsPointEls.clear();
renderOpsEquipments(state.equipments);
const details = await Promise.all(
state.units.map((u) => apiFetch(`/api/unit/${u.id}/detail`).catch(() => ({ equipments: [] })))
);
const allEquipments = details.flatMap((d) => d.equipments || []);
renderOpsEquipments(allEquipments);
} }
function renderOpsEquipments(equipments) { function renderOpsEquipments(equipments) {
@ -122,10 +109,10 @@ function renderOpsEquipments(equipments) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "ops-eq-card"; card.className = "ops-eq-card";
// Build role → point map // Build role → point map from role_points
const roleMap = {}; const roleMap = {};
(eq.points || []).forEach((p) => { (eq.role_points || []).forEach((p) => {
if (p.signal_role) roleMap[p.signal_role] = p; roleMap[p.signal_role] = p;
}); });
// Signal rows HTML (placeholders; WS will fill values) // Signal rows HTML (placeholders; WS will fill values)
@ -135,7 +122,7 @@ function renderOpsEquipments(equipments) {
return ` return `
<div class="ops-signal-row"> <div class="ops-signal-row">
<span class="ops-signal-label">${ROLE_LABELS[role] || role}</span> <span class="ops-signal-label">${ROLE_LABELS[role] || role}</span>
<span class="sig-dot sig-warn" data-ops-dot="${point.id}" data-ops-role="${role}"></span> <span class="sig-dot sig-warn" data-ops-dot="${point.point_id}" data-ops-role="${role}"></span>
</div>`; </div>`;
}).join(""); }).join("");
@ -178,9 +165,9 @@ function renderOpsEquipments(equipments) {
SIGNAL_ROLES.forEach((role) => { SIGNAL_ROLES.forEach((role) => {
const point = roleMap[role]; const point = roleMap[role];
if (!point) return; if (!point) return;
const dotEl = card.querySelector(`[data-ops-dot="${point.id}"]`); const dotEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`);
if (dotEl) { if (dotEl) {
state.opsPointEls.set(point.id, { dotEl }); state.opsPointEls.set(point.point_id, { dotEl });
if (point.point_monitor) { if (point.point_monitor) {
const m = point.point_monitor; const m = point.point_monitor;
dotEl.className = sigDotClass(role, m.quality, m.value_text); dotEl.className = sigDotClass(role, m.quality, m.value_text);