feat(health): Web 管理端设备数据集成补全 — Phase 2
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 新增告警三页面(仪表盘/列表/规则)+ 设备管理菜单种子数据
- 新增设备管理后端 API(GET /devices + DELETE /devices/{id})
- 新增设备数据查看组件 DeviceReadingsTab(原始数据 + 小时聚合)
- 新增设备管理页面 DeviceManage(列表/筛选/解绑)
- 患者详情页新增设备数据 Tab
This commit is contained in:
iven
2026-04-29 06:28:30 +08:00
parent f6ccb8a35c
commit cac61637ce
14 changed files with 784 additions and 1 deletions

View File

@@ -68,6 +68,9 @@ pub enum HealthError {
#[error("知情同意记录不存在")]
ConsentNotFound,
#[error("设备绑定不存在")]
DeviceNotFound,
#[error("告警规则不存在")]
AlertRuleNotFound,
@@ -118,6 +121,7 @@ impl From<HealthError> for AppError {
| HealthError::ThresholdNotFound
| HealthError::ConsentNotFound
| HealthError::AlertRuleNotFound
| HealthError::DeviceNotFound
| HealthError::AlertNotFound
| HealthError::DialysisPrescriptionNotFound
| HealthError::FollowUpTemplateNotFound

View File

@@ -0,0 +1,85 @@
//! 设备管理 API — 设备列表查询与解绑
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::DeleteWithVersion;
use crate::service::device_service;
use crate::state::HealthState;
/// 设备列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct DeviceListQuery {
/// 按患者 ID 筛选
pub patient_id: Option<Uuid>,
/// 按设备类型筛选
pub device_type: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
/// GET /api/v1/health/devices — 设备绑定列表
pub async fn list_devices<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<DeviceListQuery>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.devices.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let (items, total) = device_service::list_devices(
&state,
ctx.tenant_id,
query.patient_id,
query.device_type.as_deref(),
page,
page_size,
)
.await?;
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages: total.div_ceil(page_size.max(1)),
})))
}
/// DELETE /api/v1/health/devices/{id} — 解绑设备(软删除)
pub async fn unbind_device<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<DeleteWithVersion>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.devices.manage")?;
let device = device_service::unbind_device(
&state,
ctx.tenant_id,
id,
ctx.user_id,
body.version,
)
.await?;
Ok(axum::Json(ApiResponse::ok(device)))
}

View File

@@ -9,6 +9,7 @@ pub mod consent_handler;
pub mod critical_alert_handler;
pub mod critical_value_threshold_handler;
pub mod daily_monitoring_handler;
pub mod device_handler;
pub mod device_reading_handler;
pub mod diagnosis_handler;
pub mod medication_record_handler;

View File

@@ -7,7 +7,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
alert_handler, alert_rule_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler,
health_data_handler, medication_record_handler, patient_handler, points_handler, stats_handler,
};
@@ -653,6 +653,15 @@ impl HealthModule {
"/health/alert-rules/{id}/deactivate",
axum::routing::put(alert_rule_handler::deactivate),
)
// 设备管理
.route(
"/health/devices",
axum::routing::get(device_handler::list_devices),
)
.route(
"/health/devices/{id}",
axum::routing::delete(device_handler::unbind_device),
)
}
}
@@ -884,6 +893,18 @@ impl ErpModule for HealthModule {
description: "提交设备采集数据".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.devices.list".into(),
name: "查看设备绑定".into(),
description: "查看设备绑定记录列表".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.devices.manage".into(),
name: "管理设备绑定".into(),
description: "解绑设备".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.alerts.list".into(),
name: "查看告警".into(),

View File

@@ -0,0 +1,72 @@
//! 设备管理服务 — 设备绑定记录的查询与解绑
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{QueryOrder, QuerySelect};
use uuid::Uuid;
use crate::entity::patient_devices;
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
/// 查询设备绑定记录(分页),支持按 patient_id / device_type 筛选
pub async fn list_devices(
state: &HealthState,
tenant_id: Uuid,
patient_id: Option<Uuid>,
device_type: Option<&str>,
page: u64,
page_size: u64,
) -> HealthResult<(Vec<patient_devices::Model>, u64)> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = patient_devices::Entity::find()
.filter(patient_devices::Column::TenantId.eq(tenant_id))
.filter(patient_devices::Column::DeletedAt.is_null());
if let Some(pid) = patient_id {
query = query.filter(patient_devices::Column::PatientId.eq(pid));
}
if let Some(dt) = device_type {
query = query.filter(patient_devices::Column::DeviceType.eq(dt));
}
let total = query.clone().count(&state.db).await?;
let items = query
.order_by_desc(patient_devices::Column::CreatedAt)
.limit(limit)
.offset(offset)
.all(&state.db)
.await?;
Ok((items, total))
}
/// 解绑设备 — 设置 deleted_at 实现软删除,递增 version
pub async fn unbind_device(
state: &HealthState,
tenant_id: Uuid,
device_id: Uuid,
user_id: Uuid,
version: i32,
) -> HealthResult<patient_devices::Model> {
let device = patient_devices::Entity::find_by_id(device_id)
.filter(patient_devices::Column::TenantId.eq(tenant_id))
.filter(patient_devices::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::DeviceNotFound)?;
// 乐观锁校验
erp_core::error::check_version(device.version, version)?;
let mut active: patient_devices::ActiveModel = device.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(user_id));
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
}

View File

@@ -11,6 +11,7 @@ pub mod critical_alert_service;
pub mod critical_value_threshold_service;
pub mod daily_monitoring_service;
pub mod device_reading_service;
pub mod device_service;
pub mod diagnosis_service;
pub mod medication_record_service;
pub mod doctor_service;

View File

@@ -94,6 +94,7 @@ mod m20260428_000091_dead_letter_events;
mod m20260429_000092_device_readings_metric;
mod m20260429_000093_trend_analysis_prompt_v2;
mod m20260429_000094_device_readings_unique_constraint;
mod m20260429_000095_seed_alert_device_menus;
pub struct Migrator;
@@ -195,6 +196,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260429_000092_device_readings_metric::Migration),
Box::new(m20260429_000093_trend_analysis_prompt_v2::Migration),
Box::new(m20260429_000094_device_readings_unique_constraint::Migration),
Box::new(m20260429_000095_seed_alert_device_menus::Migration),
]
}
}

View File

@@ -0,0 +1,84 @@
//! 补充告警和设备管理菜单种子数据
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 获取默认租户 ID
let result = db.query_one(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
"SELECT id::text FROM tenant LIMIT 1".to_string(),
))
.await?;
let tid = match result {
Some(row) => row.try_get_by_index::<String>(0).unwrap_or_default(),
None => return Ok(()),
};
let sys = "00000000-0000-0000-0000-000000000000";
let d3 = "a0000000-0000-0000-0000-000000000003"; // 健康管理目录
// 告警相关菜单(排在 AI 用量统计之后)
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000016", "告警仪表盘", "/health/alert-dashboard", "AlertOutlined", 15, sys).await?;
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000017", "告警列表", "/health/alerts", "BellOutlined", 16, sys).await?;
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000018", "告警规则", "/health/alert-rules", "ControlOutlined", 17, sys).await?;
// 设备管理菜单
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000019", "设备管理", "/health/devices", "ApiOutlined", 18, sys).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let ids = [
"b0000003-0000-0000-0000-000000000016",
"b0000003-0000-0000-0000-000000000017",
"b0000003-0000-0000-0000-000000000018",
"b0000003-0000-0000-0000-000000000019",
];
for id in &ids {
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!("DELETE FROM menus WHERE id = '{id}'"),
))
.await
.ok();
}
Ok(())
}
}
async fn insert_menu(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
tenant_id: &str,
parent_id: &str,
id: &str,
title: &str,
path: &str,
icon: &str,
sort: i32,
sys: &str,
) -> Result<(), DbErr> {
let esc_title = title.replace('\'', "''");
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!(
"INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible, menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version) \
VALUES ('{id}', '{tenant_id}', '{parent_id}', '{esc_title}', '{path}', '{icon}', {sort}, true, 'menu', NULL, NOW(), NOW(), '{sys}', '{sys}', NULL, 1) \
ON CONFLICT (id) DO NOTHING"
),
))
.await?;
Ok(())
}