Files
hms/apps/miniprogram/src/pages/device-sync/index.tsx
iven 202c6dd0d2
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
feat(miniprogram): 小程序设备数据集成打通 — Phase 3
- 首页设备入口简化为直接跳转按钮(去除硬编码 never 状态)
- 体征录入页增加「从设备同步」入口,设备数据自动回填表单
- 设备同步页支持 returnTo 参数,完成后返回录入页
- 医护工作台增加告警中心固定导航入口(带数字角标)
2026-04-29 06:36:12 +08:00

254 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useRouter } 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';
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';
export default function DeviceSync() {
const { currentPatient } = useAuthStore();
const router = useRouter();
const returnTo = router.params.returnTo || '';
const [pageState, setPageState] = useState<PageState>('idle');
const [devices, setDevices] = useState<BLEDevice[]>([]);
const [selectedDevice, setSelectedDevice] = useState<BLEDevice | null>(null);
const [liveReadings, setLiveReadings] = useState<NormalizedReading[]>([]);
const [syncCount, setSyncCount] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
useDidShow(() => {
bleManager.setOnConnectionChange(() => {});
bleManager.setOnReadings((readings) => {
setLiveReadings((prev) => [...prev, ...readings]);
});
return () => {
bleManager.destroy();
};
});
const handleScan = useCallback(async () => {
setPageState('scanning');
setDevices([]);
setErrorMsg('');
try {
const found = await bleManager.scanDevices();
setDevices(found);
if (found.length === 0) {
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
}
setPageState('idle');
} catch (e: any) {
setErrorMsg(e.message || '扫描失败');
setPageState('error');
}
}, []);
const handleConnect = useCallback(async (device: BLEDevice) => {
setSelectedDevice(device);
setPageState('connecting');
setErrorMsg('');
try {
await bleManager.connect(device);
setPageState('connected');
} catch (e: any) {
setErrorMsg(e.message || '连接失败');
setPageState('error');
}
}, []);
const handleSync = useCallback(async () => {
if (!currentPatient || !selectedDevice) return;
setPageState('syncing');
setErrorMsg('');
try {
const result = await bleManager.syncToServer(async (readings) => {
return uploadReadings(
currentPatient.id,
selectedDevice.deviceId,
selectedDevice.name,
readings,
);
});
if (result.success) {
setSyncCount(result.uploadedCount);
setPageState('done');
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
if (returnTo === 'input' && liveReadings.length > 0) {
const mapped: Record<string, number> = {};
for (const r of liveReadings) {
if (r.device_type === 'blood_pressure') {
if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value;
if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value;
// 兼容 values 中直接包含 systolic/diastolic 的格式
if (typeof r.values.systolic === 'number') mapped.systolic = r.values.systolic as number;
if (typeof r.values.diastolic === 'number') mapped.diastolic = r.values.diastolic as number;
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
mapped.blood_sugar = r.values.blood_glucose as number;
} else if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') {
mapped.heart_rate = r.values.heart_rate as number;
}
}
if (Object.keys(mapped).length > 0) {
Taro.setStorageSync('device_sync_result', JSON.stringify(mapped));
}
}
} else {
setErrorMsg(result.error || '同步失败');
setPageState('error');
}
} catch (e: any) {
setErrorMsg(e.message || '同步失败');
setPageState('error');
}
}, [currentPatient, selectedDevice, liveReadings, returnTo]);
const handleDisconnect = useCallback(async () => {
await bleManager.disconnect();
setPageState('idle');
setSelectedDevice(null);
setLiveReadings([]);
setSyncCount(0);
setErrorMsg('');
}, []);
const renderIdle = () => (
<View className="sync-section">
<View className="sync-hero">
<Text className="sync-hero-icon">D</Text>
<Text className="sync-hero-title"></Text>
<Text className="sync-hero-desc"></Text>
</View>
<View className="sync-action" onClick={handleScan}>
<Text className="sync-action-text"></Text>
</View>
{devices.length > 0 && (
<View className="sync-device-list">
<Text className="sync-section-title"></Text>
{devices.map((d) => (
<View
key={d.deviceId}
className="sync-device-item"
onClick={() => handleConnect(d)}
>
<View className="sync-device-info">
<Text className="sync-device-name">{d.name}</Text>
<Text className="sync-device-adapter">{d.adapter?.name}</Text>
</View>
<Text className="sync-device-rssi"> {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'}</Text>
</View>
))}
</View>
)}
</View>
);
const renderConnected = () => (
<View className="sync-section">
<View className="sync-status-card">
<Text className="sync-status-dot sync-status-dot--connected" />
<Text className="sync-status-text">: {selectedDevice?.name}</Text>
</View>
{liveReadings.length > 0 && (
<View className="sync-readings-panel">
<Text className="sync-section-title"></Text>
{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 === '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`
: r.metric
? `${r.values.value} ${r.values.unit}`
: JSON.stringify(r.values)}
</Text>
</View>
))}
<Text className="sync-readings-count">
{liveReadings.length}
</Text>
</View>
)}
<View className="sync-actions-row">
<View className="sync-action sync-action--primary" onClick={handleSync}>
<Text className="sync-action-text"></Text>
</View>
<View className="sync-action sync-action--danger" onClick={handleDisconnect}>
<Text className="sync-action-text"></Text>
</View>
</View>
</View>
);
const renderDone = () => (
<View className="sync-section">
<View className="sync-result-card">
<Text className="sync-result-icon">V</Text>
<Text className="sync-result-title"></Text>
<Text className="sync-result-count"> {syncCount} </Text>
</View>
<View className="sync-action" onClick={() => {
handleDisconnect();
if (returnTo === 'input') {
Taro.navigateBack();
}
}}>
<Text className="sync-action-text">{returnTo === 'input' ? '返回录入' : '完成'}</Text>
</View>
</View>
);
return (
<View className="device-sync-page">
<View className="sync-header">
<Text className="sync-header-title"></Text>
</View>
{errorMsg && (
<View className="sync-error">
<Text className="sync-error-text">{errorMsg}</Text>
</View>
)}
{(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && (
<View className="sync-loading">
<Text className="sync-loading-text">
{pageState === 'scanning' && '正在扫描设备...'}
{pageState === 'connecting' && '正在连接设备...'}
{pageState === 'syncing' && '正在上传数据...'}
</Text>
</View>
)}
{(pageState === 'idle' || pageState === 'error') && renderIdle()}
{pageState === 'connected' && renderConnected()}
{pageState === 'done' && renderDone()}
</View>
);
}