feat(miniprogram): BLE 设备同步模块 — 扫描+连接+数据上传
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

- 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:
iven
2026-04-27 07:53:12 +08:00
parent d1ab8074a3
commit 215fb35e0e
9 changed files with 1011 additions and 0 deletions

View 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>
);
}