feat(miniprogram): BLE 设备同步模块 — 扫描+连接+数据上传
- Task 18: BLE 类型定义(NormalizedReading/DeviceAdapter/BLEDevice)+ BLEManager 连接管理器 - Task 19: XiaomiBandAdapter 心率读取适配器(标准 HRS Service 0x180D) - Task 20: device-sync API 层 + 设备同步页面 + app.config 路由注册
This commit is contained in:
216
apps/miniprogram/src/pages/device-sync/index.tsx
Normal file
216
apps/miniprogram/src/pages/device-sync/index.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
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 { 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);
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
|
||||
export default function DeviceSync() {
|
||||
const { currentPatient } = useAuthStore();
|
||||
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');
|
||||
} else {
|
||||
setErrorMsg(result.error || '同步失败');
|
||||
setPageState('error');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message || '同步失败');
|
||||
setPageState('error');
|
||||
}
|
||||
}, [currentPatient, selectedDevice]);
|
||||
|
||||
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">⌚</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}
|
||||
</Text>
|
||||
<Text className="sync-reading-value">
|
||||
{r.device_type === 'heart_rate'
|
||||
? `${r.values.heart_rate} bpm`
|
||||
: 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">✓</Text>
|
||||
<Text className="sync-result-title">同步完成</Text>
|
||||
<Text className="sync-result-count">成功上传 {syncCount} 条数据</Text>
|
||||
</View>
|
||||
<View className="sync-action" onClick={handleDisconnect}>
|
||||
<Text className="sync-action-text">完成</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user