) => JSON.stringify(v),
+ },
+ {
+ title: '设备型号',
+ dataIndex: 'device_model',
+ key: 'device_model',
+ width: 120,
+ render: (v?: string) => v ?? '-',
+ },
+ ],
+ [],
+ );
+
+ return (
+ <>
+
+
+
+ refresh(p),
+ showTotal: (t) => `共 ${t} 条`,
+ size: 'small',
+ }}
+ />
+ >
+ );
+}
+
+/* ---------- 小时聚合 Tab ---------- */
+
+interface HourlyFilters {
+ deviceType: string | undefined;
+ days: number;
+}
+
+function HourlyAggTab({ patientId }: Props) {
+ const [deviceType, setDeviceType] = useState(undefined);
+ const [days, setDays] = useState(7);
+
+ const fetcher = useCallback(
+ async (page: number, pageSize: number) => {
+ if (!deviceType) {
+ return { data: [] as HourlyReading[], total: 0 };
+ }
+ return deviceReadingApi.queryHourly({
+ patient_id: patientId,
+ device_type: deviceType,
+ days,
+ page,
+ page_size: pageSize,
+ });
+ },
+ [patientId, deviceType, days],
+ );
+
+ const { data, total, page, loading, refresh } = usePaginatedData(
+ fetcher,
+ PAGE_SIZE,
+ false,
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ title: '小时起始时间',
+ dataIndex: 'hour_start',
+ key: 'hour_start',
+ width: 180,
+ render: (v: string) => v ?? '-',
+ },
+ {
+ title: '设备类型',
+ dataIndex: 'device_type',
+ key: 'device_type',
+ width: 100,
+ render: (v: string) => DEVICE_TYPE_MAP[v] ?? v,
+ },
+ {
+ title: '最小值',
+ dataIndex: 'min_val',
+ key: 'min_val',
+ width: 100,
+ render: (v?: number) => (v != null ? v.toFixed(2) : '-'),
+ },
+ {
+ title: '最大值',
+ dataIndex: 'max_val',
+ key: 'max_val',
+ width: 100,
+ render: (v?: number) => (v != null ? v.toFixed(2) : '-'),
+ },
+ {
+ title: '平均值',
+ dataIndex: 'avg_val',
+ key: 'avg_val',
+ width: 100,
+ render: (v: number) => v.toFixed(2),
+ },
+ {
+ title: '采样数',
+ dataIndex: 'sample_count',
+ key: 'sample_count',
+ width: 80,
+ },
+ ],
+ [],
+ );
+
+ return (
+ <>
+
+ {
+ setDeviceType(v);
+ refresh(1);
+ }}
+ />
+ {
+ setDays(v);
+ refresh(1);
+ }}
+ />
+
+
+ {!deviceType ? (
+ 请先选择设备类型
+ ) : (
+ refresh(p),
+ showTotal: (t) => `共 ${t} 条`,
+ size: 'small',
+ }}
+ />
+ )}
+ >
+ );
+}
+
+/* ---------- 主组件 ---------- */
+
+export function DeviceReadingsTab({ patientId }: Props) {
+ return (
+
+ ,
+ },
+ {
+ key: 'hourly',
+ label: '小时聚合',
+ children: ,
+ },
+ ]}
+ />
+
+ );
+}
diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs
index c803e86..c0fb0a1 100644
--- a/crates/erp-health/src/error.rs
+++ b/crates/erp-health/src/error.rs
@@ -68,6 +68,9 @@ pub enum HealthError {
#[error("知情同意记录不存在")]
ConsentNotFound,
+ #[error("设备绑定不存在")]
+ DeviceNotFound,
+
#[error("告警规则不存在")]
AlertRuleNotFound,
@@ -118,6 +121,7 @@ impl From for AppError {
| HealthError::ThresholdNotFound
| HealthError::ConsentNotFound
| HealthError::AlertRuleNotFound
+ | HealthError::DeviceNotFound
| HealthError::AlertNotFound
| HealthError::DialysisPrescriptionNotFound
| HealthError::FollowUpTemplateNotFound
diff --git a/crates/erp-health/src/handler/device_handler.rs b/crates/erp-health/src/handler/device_handler.rs
new file mode 100644
index 0000000..a6382bd
--- /dev/null
+++ b/crates/erp-health/src/handler/device_handler.rs
@@ -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,
+ /// 按设备类型筛选
+ pub device_type: Option,
+ pub page: Option,
+ pub page_size: Option,
+}
+
+/// GET /api/v1/health/devices — 设备绑定列表
+pub async fn list_devices(
+ State(state): State,
+ Extension(ctx): Extension,
+ Query(query): Query,
+) -> Result
+where
+ HealthState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(id): Path,
+ axum::Json(body): axum::Json,
+) -> Result
+where
+ HealthState: FromRef,
+ 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)))
+}
diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs
index 26f2b8e..086e7b7 100644
--- a/crates/erp-health/src/handler/mod.rs
+++ b/crates/erp-health/src/handler/mod.rs
@@ -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;
diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs
index 51d3901..3d188f8 100644
--- a/crates/erp-health/src/module.rs
+++ b/crates/erp-health/src/module.rs
@@ -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(),
diff --git a/crates/erp-health/src/service/device_service.rs b/crates/erp-health/src/service/device_service.rs
new file mode 100644
index 0000000..fb7a474
--- /dev/null
+++ b/crates/erp-health/src/service/device_service.rs
@@ -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,
+ device_type: Option<&str>,
+ page: u64,
+ page_size: u64,
+) -> HealthResult<(Vec, 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 {
+ 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?)
+}
diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs
index 2183fb6..81ffd7a 100644
--- a/crates/erp-health/src/service/mod.rs
+++ b/crates/erp-health/src/service/mod.rs
@@ -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;
diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs
index 46a5cf6..3bf66d6 100644
--- a/crates/erp-server/migration/src/lib.rs
+++ b/crates/erp-server/migration/src/lib.rs
@@ -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),
]
}
}
diff --git a/crates/erp-server/migration/src/m20260429_000095_seed_alert_device_menus.rs b/crates/erp-server/migration/src/m20260429_000095_seed_alert_device_menus.rs
new file mode 100644
index 0000000..d6ee38c
--- /dev/null
+++ b/crates/erp-server/migration/src/m20260429_000095_seed_alert_device_menus.rs
@@ -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::(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(())
+}