feat(health): Web 管理端设备数据集成补全 — Phase 2
- 新增告警三页面(仪表盘/列表/规则)+ 设备管理菜单种子数据
- 新增设备管理后端 API(GET /devices + DELETE /devices/{id})
- 新增设备数据查看组件 DeviceReadingsTab(原始数据 + 小时聚合)
- 新增设备管理页面 DeviceManage(列表/筛选/解绑)
- 患者详情页新增设备数据 Tab
This commit is contained in:
@@ -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
|
||||
|
||||
85
crates/erp-health/src/handler/device_handler.rs
Normal file
85
crates/erp-health/src/handler/device_handler.rs
Normal 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)))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
72
crates/erp-health/src/service/device_service.rs
Normal file
72
crates/erp-health/src/service/device_service.rs
Normal 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?)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user