Compare commits
5 Commits
e7b2e6382a
...
e76f4feb4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76f4feb4f | ||
|
|
601b2d7f52 | ||
|
|
00f615d8e5 | ||
|
|
8a61ae3f8e | ||
|
|
d715647a73 |
51
apps/miniprogram/src/components/DeviceCard/index.scss
Normal file
51
apps/miniprogram/src/components/DeviceCard/index.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.device-icon {
|
||||
font-size: 48rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
|
||||
.device-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
font-size: 24rpx;
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
|
||||
&.connected { color: $pri; }
|
||||
&.idle { color: $tx3; }
|
||||
}
|
||||
|
||||
.last-sync {
|
||||
font-size: 22rpx;
|
||||
color: $tx3;
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
padding: 12rpx 28rpx;
|
||||
background: $pri;
|
||||
color: #fff;
|
||||
border-radius: $r-pill;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
39
apps/miniprogram/src/components/DeviceCard/index.tsx
Normal file
39
apps/miniprogram/src/components/DeviceCard/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './index.scss';
|
||||
|
||||
interface DeviceCardProps {
|
||||
deviceName: string;
|
||||
deviceType: string;
|
||||
lastSyncAt?: string;
|
||||
status: 'connected' | 'disconnected' | 'never';
|
||||
}
|
||||
|
||||
const DEVICE_ICONS: Record<string, string> = {
|
||||
blood_pressure: '\u{1FA7A}',
|
||||
blood_glucose: '\u{1FA78}',
|
||||
heart_rate: '\u{2764}',
|
||||
blood_oxygen: '\u{1FAB1}',
|
||||
};
|
||||
|
||||
export default function DeviceCard({ deviceName, deviceType, lastSyncAt, status }: DeviceCardProps) {
|
||||
const icon = DEVICE_ICONS[deviceType] || '\u{1F4F1}';
|
||||
const statusLabel = status === 'connected' ? '已连接' : status === 'disconnected' ? '未连接' : '未配对';
|
||||
const statusClass = status === 'connected' ? 'connected' : 'idle';
|
||||
|
||||
const handleSync = () => {
|
||||
Taro.navigateTo({ url: '/pages/device-sync/index' });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='device-card' onClick={handleSync}>
|
||||
<View className='device-icon'>{icon}</View>
|
||||
<View className='device-info'>
|
||||
<Text className='device-name'>{deviceName}</Text>
|
||||
<Text className={`device-status ${statusClass}`}>{statusLabel}</Text>
|
||||
{lastSyncAt && <Text className='last-sync'>最近同步: {lastSyncAt}</Text>}
|
||||
</View>
|
||||
<View className='sync-btn'>同步</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { View, Text } from '@tarojs/components';
|
||||
import { useDidShow } from '@tarojs/taro';
|
||||
import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
|
||||
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
||||
@@ -10,6 +12,8 @@ import './index.scss';
|
||||
|
||||
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
||||
bleManager.registerAdapter(XiaomiBandAdapter);
|
||||
bleManager.registerAdapter(BloodPressureAdapter);
|
||||
bleManager.registerAdapter(GlucoseMeterAdapter);
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
|
||||
@@ -41,7 +45,7 @@ export default function DeviceSync() {
|
||||
const found = await bleManager.scanDevices();
|
||||
setDevices(found);
|
||||
if (found.length === 0) {
|
||||
setErrorMsg('未发现支持的设备,请确认手环已开启蓝牙并靠近手机');
|
||||
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
|
||||
}
|
||||
setPageState('idle');
|
||||
} catch (e: any) {
|
||||
@@ -106,7 +110,7 @@ export default function DeviceSync() {
|
||||
<View className="sync-hero">
|
||||
<Text className="sync-hero-icon">D</Text>
|
||||
<Text className="sync-hero-title">设备同步</Text>
|
||||
<Text className="sync-hero-desc">连接智能手环,自动采集健康数据</Text>
|
||||
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||
</View>
|
||||
|
||||
<View className="sync-action" onClick={handleScan}>
|
||||
@@ -147,12 +151,17 @@ export default function DeviceSync() {
|
||||
{liveReadings.slice(-5).reverse().map((r, i) => (
|
||||
<View key={i} className="sync-reading-item">
|
||||
<Text className="sync-reading-type">
|
||||
{r.device_type === 'heart_rate' ? '心率' : r.device_type}
|
||||
{r.device_type === 'heart_rate' ? '心率'
|
||||
: r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})`
|
||||
: r.device_type === 'blood_glucose' ? '血糖'
|
||||
: r.device_type}
|
||||
</Text>
|
||||
<Text className="sync-reading-value">
|
||||
{r.device_type === 'heart_rate'
|
||||
? `${r.values.heart_rate} bpm`
|
||||
: JSON.stringify(r.values)}
|
||||
: r.metric
|
||||
? `${r.values.value} ${r.values.unit}`
|
||||
: JSON.stringify(r.values)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import DeviceCard from '../../components/DeviceCard';
|
||||
import Loading from '../../components/Loading';
|
||||
import { trackPageView } from '@/services/analytics';
|
||||
import * as appointmentApi from '@/services/appointment';
|
||||
@@ -13,8 +13,8 @@ import './index.scss';
|
||||
|
||||
const QUICK_SERVICES = [
|
||||
{ label: '预约挂号', char: '约', path: '/pages/appointment/create/index' },
|
||||
{ label: '健康录入', char: '录', path: '/pages/health/input/index' },
|
||||
{ label: '健康趋势', char: '势', path: '/pages/health/trend/index' },
|
||||
{ label: '健康录入', char: '录', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', char: '势', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '资讯文章', char: '文', path: '/pages/article/index' },
|
||||
{ label: 'AI 报告', char: 'AI', path: '/pages/ai-report/list/index' },
|
||||
];
|
||||
@@ -123,6 +123,20 @@ export default function Index() {
|
||||
<Text className='greeting-date'>{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}</Text>
|
||||
</View>
|
||||
|
||||
{/* 设备快捷入口 */}
|
||||
<View className='device-section'>
|
||||
<DeviceCard
|
||||
deviceName='血压计'
|
||||
deviceType='blood_pressure'
|
||||
status='never'
|
||||
/>
|
||||
<DeviceCard
|
||||
deviceName='血糖仪'
|
||||
deviceType='blood_glucose'
|
||||
status='never'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 今日健康 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>今日健康</Text>
|
||||
@@ -132,7 +146,7 @@ export default function Index() {
|
||||
<View className='health-empty'>
|
||||
<Text className='health-empty-text'>今天还没录入数据</Text>
|
||||
<View className='health-empty-action'>
|
||||
<View className='health-empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/health/input/index' })}>
|
||||
<View className='health-empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/pkg-health/input/index' })}>
|
||||
<Text className='health-empty-btn-text'>点击开始记录</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -142,7 +156,7 @@ export default function Index() {
|
||||
{healthItems.map((item) => {
|
||||
const tag = getStatusTag(item.status);
|
||||
return (
|
||||
<View className='health-cell' key={item.label} onClick={() => Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${item.label === '血压' ? 'blood_pressure_systolic' : item.label === '心率' ? 'heart_rate' : item.label === '血糖' ? 'blood_sugar_fasting' : 'weight'}` })}>
|
||||
<View className='health-cell' key={item.label} onClick={() => Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${item.label === '血压' ? 'blood_pressure_systolic' : item.label === '心率' ? 'heart_rate' : item.label === '血糖' ? 'blood_sugar_fasting' : 'weight'}` })}>
|
||||
<Text className='health-cell-label'>{item.label}</Text>
|
||||
<Text className='health-cell-value'>{item.value}</Text>
|
||||
<View className='health-cell-bottom'>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { DeviceAdapter, NormalizedReading } from '../types';
|
||||
|
||||
// Bluetooth SIG Blood Pressure Service
|
||||
const BPS_SERVICE = '00001810-0000-1000-8000-00805f9b34fb';
|
||||
const BPM_CHARACTERISTIC = '00002A35-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
interface BPMData {
|
||||
systolic: number;
|
||||
diastolic: number;
|
||||
map: number;
|
||||
unit: string;
|
||||
pulseRate?: number;
|
||||
}
|
||||
|
||||
// SFLOAT (IEEE 11073 16-bit float): mantissa bits 0-11, exponent bits 12-15
|
||||
function readSfloat(view: DataView, byteOffset: number): number {
|
||||
const raw = view.getUint16(byteOffset, true);
|
||||
const mantissa = raw & 0x0FFF;
|
||||
const exponent = (raw >> 12) & 0x0F;
|
||||
const signedMantissa = mantissa > 0x07FF ? mantissa - 0x1000 : mantissa;
|
||||
const signedExponent = exponent > 0x07 ? exponent - 0x10 : exponent;
|
||||
return signedMantissa * Math.pow(10, signedExponent);
|
||||
}
|
||||
|
||||
function parseBloodPressureMeasurement(data: ArrayBuffer): BPMData | null {
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 7) return null;
|
||||
|
||||
let offset = 0;
|
||||
const flags = view.getUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
const systolic = readSfloat(view, offset);
|
||||
offset += 2;
|
||||
const diastolic = readSfloat(view, offset);
|
||||
offset += 2;
|
||||
const map = readSfloat(view, offset);
|
||||
offset += 2;
|
||||
|
||||
let pulseRate: number | undefined;
|
||||
if (flags & 0x01) offset += 7; // timestamp
|
||||
if (flags & 0x02) {
|
||||
pulseRate = readSfloat(view, offset);
|
||||
}
|
||||
|
||||
return {
|
||||
systolic: Math.round(systolic * 10) / 10,
|
||||
diastolic: Math.round(diastolic * 10) / 10,
|
||||
map: Math.round(map * 10) / 10,
|
||||
unit: 'mmHg',
|
||||
pulseRate,
|
||||
};
|
||||
}
|
||||
|
||||
export const BloodPressureAdapter: DeviceAdapter = {
|
||||
name: 'Blood Pressure Monitor',
|
||||
supportedModels: [
|
||||
'AND UA-651BLE',
|
||||
'Omron HEM-7322',
|
||||
'Omron BLE',
|
||||
'BP Monitor',
|
||||
'A&D BLE',
|
||||
'iHealth BP',
|
||||
'Beurer BM',
|
||||
'Yuwell BLE',
|
||||
],
|
||||
serviceUUIDs: [BPS_SERVICE],
|
||||
notifyCharacteristics: [
|
||||
{ service: BPS_SERVICE, characteristic: BPM_CHARACTERISTIC },
|
||||
],
|
||||
readCharacteristics: [
|
||||
{ service: BPS_SERVICE, characteristic: BPM_CHARACTERISTIC },
|
||||
],
|
||||
|
||||
parseNotification(
|
||||
_serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading[] {
|
||||
if (charUUID.toUpperCase() !== BPM_CHARACTERISTIC.toUpperCase()) return [];
|
||||
|
||||
const parsed = parseBloodPressureMeasurement(data);
|
||||
if (!parsed) return [];
|
||||
|
||||
const measuredAt = new Date().toISOString();
|
||||
const readings: NormalizedReading[] = [
|
||||
{
|
||||
device_type: 'blood_pressure',
|
||||
metric: 'systolic',
|
||||
values: { value: parsed.systolic, unit: parsed.unit },
|
||||
measured_at: measuredAt,
|
||||
},
|
||||
{
|
||||
device_type: 'blood_pressure',
|
||||
metric: 'diastolic',
|
||||
values: { value: parsed.diastolic, unit: parsed.unit },
|
||||
measured_at: measuredAt,
|
||||
},
|
||||
{
|
||||
device_type: 'blood_pressure',
|
||||
metric: 'map',
|
||||
values: { value: parsed.map, unit: parsed.unit },
|
||||
measured_at: measuredAt,
|
||||
},
|
||||
];
|
||||
|
||||
if (parsed.pulseRate != null) {
|
||||
readings.push({
|
||||
device_type: 'heart_rate',
|
||||
metric: 'pulse_rate',
|
||||
values: { value: parsed.pulseRate, unit: 'bpm' },
|
||||
measured_at: measuredAt,
|
||||
});
|
||||
}
|
||||
|
||||
return readings;
|
||||
},
|
||||
|
||||
parseReadResponse(
|
||||
serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading[] {
|
||||
return this.parseNotification(serviceUUID, charUUID, data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { DeviceAdapter, NormalizedReading } from '../types';
|
||||
|
||||
// Bluetooth SIG Glucose Service
|
||||
const GLS_SERVICE = '00001808-0000-1000-8000-00805f9b34fb';
|
||||
const GLM_CHARACTERISTIC = '00002A18-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
type GlucoseType =
|
||||
| 'capillary_whole'
|
||||
| 'capillary_plasma'
|
||||
| 'venous_whole'
|
||||
| 'venous_plasma'
|
||||
| 'arterial_whole'
|
||||
| 'arterial_plasma'
|
||||
| 'undetermined'
|
||||
| 'control';
|
||||
|
||||
const GLUCOSE_TYPE_LABELS: Record<number, GlucoseType> = {
|
||||
1: 'capillary_whole',
|
||||
2: 'capillary_plasma',
|
||||
3: 'venous_whole',
|
||||
4: 'venous_plasma',
|
||||
5: 'arterial_whole',
|
||||
6: 'arterial_plasma',
|
||||
7: 'undetermined',
|
||||
8: 'control',
|
||||
};
|
||||
|
||||
// SFLOAT (IEEE 11073 16-bit float)
|
||||
function readSfloat(view: DataView, byteOffset: number): number {
|
||||
const raw = view.getUint16(byteOffset, true);
|
||||
const mantissa = raw & 0x0FFF;
|
||||
const exponent = (raw >> 12) & 0x0F;
|
||||
const signedMantissa = mantissa > 0x07FF ? mantissa - 0x1000 : mantissa;
|
||||
const signedExponent = exponent > 0x07 ? exponent - 0x10 : exponent;
|
||||
return signedMantissa * Math.pow(10, signedExponent);
|
||||
}
|
||||
|
||||
interface GlucoseMeasurement {
|
||||
concentration: number;
|
||||
unit: string;
|
||||
type: GlucoseType;
|
||||
timestamp: Date;
|
||||
sequenceNumber: number;
|
||||
}
|
||||
|
||||
function parseGlucoseMeasurement(data: ArrayBuffer): GlucoseMeasurement | null {
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 3) return null;
|
||||
|
||||
let offset = 0;
|
||||
const flags = view.getUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
const sequenceNumber = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
|
||||
const year = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const month = view.getUint8(offset);
|
||||
offset += 1;
|
||||
const day = view.getUint8(offset);
|
||||
offset += 1;
|
||||
const hours = view.getUint8(offset);
|
||||
offset += 1;
|
||||
const minutes = view.getUint8(offset);
|
||||
offset += 1;
|
||||
const seconds = view.getUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
const timestamp = new Date(year, month - 1, day, hours, minutes, seconds);
|
||||
|
||||
if (flags & 0x01) offset += 2; // time offset
|
||||
|
||||
const concentration = readSfloat(view, offset);
|
||||
offset += 2;
|
||||
const typeAndLocation = view.getUint8(offset);
|
||||
const glucoseType: GlucoseType =
|
||||
GLUCOSE_TYPE_LABELS[(typeAndLocation >> 4) & 0x0F] || 'undetermined';
|
||||
|
||||
// kg/L → mmol/L (分子量 ~180.16 g/mol, 1 kg/L = 5546 mmol/L)
|
||||
const concentrationMmol = Math.round(concentration * 5546 * 10) / 10;
|
||||
|
||||
return {
|
||||
concentration: concentrationMmol,
|
||||
unit: 'mmol/L',
|
||||
type: glucoseType,
|
||||
timestamp,
|
||||
sequenceNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export const GlucoseMeterAdapter: DeviceAdapter = {
|
||||
name: 'Glucose Meter',
|
||||
supportedModels: [
|
||||
'Accu-Chek',
|
||||
'Contour',
|
||||
'OneTouch',
|
||||
'FreeStyle Libre',
|
||||
'Glucose Meter',
|
||||
'iHealth BG',
|
||||
'Yuwell GLU',
|
||||
'Bionime',
|
||||
'Sinocare',
|
||||
],
|
||||
serviceUUIDs: [GLS_SERVICE],
|
||||
notifyCharacteristics: [
|
||||
{ service: GLS_SERVICE, characteristic: GLM_CHARACTERISTIC },
|
||||
],
|
||||
readCharacteristics: [
|
||||
{ service: GLS_SERVICE, characteristic: GLM_CHARACTERISTIC },
|
||||
],
|
||||
|
||||
parseNotification(
|
||||
_serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading[] {
|
||||
if (charUUID.toUpperCase() !== GLM_CHARACTERISTIC.toUpperCase()) return [];
|
||||
|
||||
const parsed = parseGlucoseMeasurement(data);
|
||||
if (!parsed) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
device_type: 'blood_glucose',
|
||||
metric: parsed.type,
|
||||
values: { value: parsed.concentration, unit: parsed.unit },
|
||||
measured_at: parsed.timestamp.toISOString(),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
parseReadResponse(
|
||||
serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading[] {
|
||||
return this.parseNotification(serviceUUID, charUUID, data);
|
||||
},
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
||||
export { BloodPressureAdapter } from './BloodPressureAdapter';
|
||||
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';
|
||||
|
||||
@@ -5,6 +5,8 @@ export const TEMPLATE_IDS = {
|
||||
APPOINTMENT_REMINDER: '',
|
||||
FOLLOWUP_REMINDER: '',
|
||||
REPORT_NOTIFICATION: '',
|
||||
CRITICAL_HEALTH_ALERT: '',
|
||||
HEALTH_DATA_ABNORMAL: '',
|
||||
} as const;
|
||||
|
||||
/** 检查模板 ID 是否已配置,未配置时返回 false 并打印警告 */
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
if let Some(pid) = patient_id {
|
||||
// 对所有设备类型触发评估
|
||||
for device_type in &["heart_rate", "blood_oxygen", "temperature"] {
|
||||
for device_type in &["heart_rate", "blood_oxygen", "temperature", "blood_pressure", "blood_glucose"] {
|
||||
if let Err(e) = crate::service::alert_engine::evaluate_rules(
|
||||
&eval_state, event.tenant_id, pid, device_type,
|
||||
).await {
|
||||
@@ -159,6 +159,46 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
|
||||
|
||||
// ── P1 事件消费者补全 ──
|
||||
|
||||
// alert.triggered → 告警消息通知
|
||||
let (mut alert_rx, _alert_handle) = state.event_bus.subscribe_filtered("alert.".to_string());
|
||||
let alert_db = state.db.clone();
|
||||
let alert_bus = state.event_bus.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match alert_rx.recv().await {
|
||||
Some(event) if event.event_type == ALERT_TRIGGERED => {
|
||||
if erp_core::events::is_event_processed(&alert_db, event.id, "alert_notifier").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let patient_id = event.payload.get("patient_id").and_then(|v| v.as_str());
|
||||
let severity = event.payload.get("severity").and_then(|v| v.as_str()).unwrap_or("warning");
|
||||
let rule_name = event.payload.get("rule_name").and_then(|v| v.as_str()).unwrap_or("健康告警");
|
||||
if let Some(pid) = patient_id {
|
||||
let notify_event = erp_core::events::DomainEvent::new(
|
||||
"message.send",
|
||||
event.tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"channel": "in_app",
|
||||
"recipient_type": "patient",
|
||||
"recipient_id": pid,
|
||||
"template_key": if severity == "critical" { "CRITICAL_HEALTH_ALERT" } else { "HEALTH_DATA_ABNORMAL" },
|
||||
"params": {
|
||||
"rule_name": rule_name,
|
||||
"severity": severity,
|
||||
}
|
||||
})),
|
||||
);
|
||||
alert_bus.publish(notify_event, &alert_db).await;
|
||||
tracing::info!(patient_id = %pid, severity = %severity, "告警通知已发送");
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&alert_db, event.id, "alert_notifier").await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// patient.created → 欢迎消息通知
|
||||
let (mut patient_rx, _patient_handle) = state.event_bus.subscribe_filtered("patient.".to_string());
|
||||
let patient_db = state.db.clone();
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::{alert_rules, alerts, vital_signs_hourly};
|
||||
use crate::entity::{alert_rules, alerts, device_readings, vital_signs_hourly};
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -62,6 +62,9 @@ pub async fn evaluate_rules(
|
||||
let condition_type = rule.condition_type.as_str();
|
||||
|
||||
let is_triggered = match condition_type {
|
||||
"single_threshold" if matches!(device_type, "blood_pressure" | "blood_glucose") => {
|
||||
evaluate_bp_glucose_threshold(&state.db, tenant_id, patient_id, device_type, params).await
|
||||
}
|
||||
"single_threshold" => evaluate_single_threshold_in_memory(&hourly_records, params),
|
||||
"consecutive" => evaluate_consecutive_in_memory(&hourly_records, params),
|
||||
"trend" => evaluate_trend_in_memory(&hourly_records, params),
|
||||
@@ -213,3 +216,38 @@ async fn create_alert_and_notify(
|
||||
|
||||
Ok(alert)
|
||||
}
|
||||
|
||||
/// 血压/血糖告警:直接查 device_readings 最新值,支持 metric 过滤
|
||||
async fn evaluate_bp_glucose_threshold(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
device_type: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> bool {
|
||||
let direction = params["direction"].as_str().unwrap_or("above");
|
||||
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
|
||||
let metric = params["metric"].as_str();
|
||||
|
||||
let mut query = device_readings::Entity::find()
|
||||
.filter(device_readings::Column::TenantId.eq(tenant_id))
|
||||
.filter(device_readings::Column::PatientId.eq(patient_id))
|
||||
.filter(device_readings::Column::DeviceType.eq(device_type))
|
||||
.filter(device_readings::Column::DeletedAt.is_null())
|
||||
.order_by_desc(device_readings::Column::MeasuredAt);
|
||||
|
||||
if let Some(m) = metric {
|
||||
query = query.filter(device_readings::Column::Metric.eq(m));
|
||||
}
|
||||
|
||||
let latest = query.one(db).await.ok().flatten();
|
||||
let Some(record) = latest else { return false };
|
||||
|
||||
let val = record.raw_value.get("value").and_then(|v| v.as_f64()).unwrap_or(f64::MAX);
|
||||
|
||||
match direction {
|
||||
"above" => val > threshold,
|
||||
"below" => val < threshold,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use uuid::Uuid;
|
||||
use erp_core::events::DomainEvent;
|
||||
use erp_core::types::PaginatedResponse;
|
||||
|
||||
use crate::entity::{device_readings, patient, patient_devices, vital_signs_hourly};
|
||||
use crate::entity::{device_readings, patient, patient_devices, vital_signs, vital_signs_hourly};
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
use crate::service::validation::validate_device_type;
|
||||
use crate::state::HealthState;
|
||||
@@ -118,6 +118,13 @@ pub async fn batch_create_readings(
|
||||
&parsed_readings,
|
||||
).await?;
|
||||
|
||||
// 4.5 双写 vital_signs(血压/血糖自动归档)
|
||||
if let Err(e) = sync_bp_glucose_to_vital_signs(
|
||||
&state.db, tenant_id, patient_id, &parsed_readings,
|
||||
).await {
|
||||
tracing::warn!(error = %e, "双写 vital_signs 失败(不影响主流程)");
|
||||
}
|
||||
|
||||
// 5. 降采样 upsert
|
||||
upsert_hourly_aggregates(
|
||||
&state.db, tenant_id, patient_id, &parsed_readings,
|
||||
@@ -440,3 +447,88 @@ pub async fn query_hourly_readings(
|
||||
total_pages: total.div_ceil(limit.max(1)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 将血压/血糖设备数据同步到 vital_signs 表
|
||||
async fn sync_bp_glucose_to_vital_signs(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
readings: &[(&ReadingInput, DateTime<Utc>)],
|
||||
) -> HealthResult<()> {
|
||||
let bp_readings: Vec<_> = readings
|
||||
.iter()
|
||||
.filter(|(r, _)| matches!(r.device_type.as_str(), "blood_pressure" | "blood_glucose"))
|
||||
.collect();
|
||||
|
||||
if bp_readings.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let today = Utc::now().date_naive();
|
||||
|
||||
let existing = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs::Column::RecordDate.eq(today))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
let mut model = if let Some(rec) = existing {
|
||||
let mut m: vital_signs::ActiveModel = rec.into();
|
||||
m.updated_at = Set(Utc::now());
|
||||
m.version = Set(m.version.unwrap() + 1);
|
||||
m
|
||||
} else {
|
||||
vital_signs::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(patient_id),
|
||||
record_date: Set(today),
|
||||
source: Set("device_auto".into()),
|
||||
created_at: Set(Utc::now()),
|
||||
updated_at: Set(Utc::now()),
|
||||
created_by: Set(None),
|
||||
updated_by: Set(None),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
for (r, _) in &bp_readings {
|
||||
let metric = r.values.get("metric").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = r.values.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
match (r.device_type.as_str(), metric) {
|
||||
("blood_pressure", "systolic") => {
|
||||
model.systolic_bp_morning = Set(Some(value.round() as i32));
|
||||
changed = true;
|
||||
}
|
||||
("blood_pressure", "diastolic") => {
|
||||
model.diastolic_bp_morning = Set(Some(value.round() as i32));
|
||||
changed = true;
|
||||
}
|
||||
("blood_glucose", _) => {
|
||||
let dec_val = sea_orm::prelude::Decimal::from_f64_retain(value).unwrap_or_default();
|
||||
model.blood_sugar = Set(Some(dec_val));
|
||||
changed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
let is_update = matches!(model.version, Set(v) if v > 1);
|
||||
if is_update {
|
||||
model.update(db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
} else {
|
||||
model.insert(db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -91,6 +91,53 @@ pub async fn seed_tenant_health(
|
||||
"urgent",
|
||||
120,
|
||||
),
|
||||
// 血压告警规则
|
||||
(
|
||||
"血压收缩压偏高",
|
||||
Some("收缩压 ≥ 140 mmHg"),
|
||||
"blood_pressure",
|
||||
"single_threshold",
|
||||
json!({"direction": "above", "value": 140.0, "metric": "systolic"}),
|
||||
"warning",
|
||||
60,
|
||||
),
|
||||
(
|
||||
"血压收缩压危急",
|
||||
Some("收缩压 ≥ 180 mmHg"),
|
||||
"blood_pressure",
|
||||
"single_threshold",
|
||||
json!({"direction": "above", "value": 180.0, "metric": "systolic"}),
|
||||
"critical",
|
||||
30,
|
||||
),
|
||||
(
|
||||
"血压舒张压偏低",
|
||||
Some("舒张压 < 60 mmHg"),
|
||||
"blood_pressure",
|
||||
"single_threshold",
|
||||
json!({"direction": "below", "value": 60.0, "metric": "diastolic"}),
|
||||
"critical",
|
||||
30,
|
||||
),
|
||||
// 血糖告警规则
|
||||
(
|
||||
"空腹血糖偏高",
|
||||
Some("空腹血糖 ≥ 7.0 mmol/L"),
|
||||
"blood_glucose",
|
||||
"single_threshold",
|
||||
json!({"direction": "above", "value": 7.0}),
|
||||
"warning",
|
||||
60,
|
||||
),
|
||||
(
|
||||
"低血糖",
|
||||
Some("血糖 < 3.9 mmol/L"),
|
||||
"blood_glucose",
|
||||
"single_threshold",
|
||||
json!({"direction": "below", "value": 3.9}),
|
||||
"critical",
|
||||
30,
|
||||
),
|
||||
];
|
||||
|
||||
for (name, description, device_type, condition_type, condition_params, severity, cooldown) in
|
||||
|
||||
Reference in New Issue
Block a user