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,235 @@
.device-sync-page {
min-height: 100vh;
background: #F1F5F9;
padding-bottom: env(safe-area-inset-bottom);
}
.sync-header {
background: linear-gradient(135deg, #0891B2, #0E7490);
padding: 48px 24px 24px;
color: #fff;
}
.sync-header-title {
font-size: 20px;
font-weight: 600;
}
.sync-section {
padding: 16px;
}
.sync-hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px;
background: #fff;
border-radius: 12px;
margin-bottom: 16px;
}
.sync-hero-icon {
font-size: 48px;
margin-bottom: 12px;
}
.sync-hero-title {
font-size: 18px;
font-weight: 600;
color: #1E293B;
margin-bottom: 4px;
}
.sync-hero-desc {
font-size: 13px;
color: #64748B;
}
.sync-action {
display: flex;
align-items: center;
justify-content: center;
background: #0891B2;
border-radius: 8px;
padding: 12px 24px;
margin: 8px 0;
}
.sync-action--primary {
flex: 1;
background: #0891B2;
}
.sync-action--danger {
flex: 1;
background: #EF4444;
margin-left: 12px;
}
.sync-action-text {
color: #fff;
font-size: 15px;
font-weight: 500;
}
.sync-device-list {
margin-top: 12px;
}
.sync-section-title {
font-size: 14px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
display: block;
}
.sync-device-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 8px;
}
.sync-device-info {
display: flex;
flex-direction: column;
}
.sync-device-name {
font-size: 15px;
font-weight: 500;
color: #1E293B;
}
.sync-device-adapter {
font-size: 12px;
color: #94A3B8;
margin-top: 2px;
}
.sync-device-rssi {
font-size: 12px;
color: #64748B;
}
.sync-status-card {
display: flex;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
}
.sync-status-dot {
width: 8px;
height: 8px;
border-radius: 4px;
margin-right: 8px;
background: #94A3B8;
}
.sync-status-dot--connected {
background: #22C55E;
}
.sync-status-text {
font-size: 14px;
color: #1E293B;
}
.sync-readings-panel {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
}
.sync-reading-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #F1F5F9;
}
.sync-reading-type {
font-size: 13px;
color: #64748B;
}
.sync-reading-value {
font-size: 15px;
font-weight: 600;
color: #0891B2;
}
.sync-readings-count {
display: block;
margin-top: 8px;
font-size: 12px;
color: #94A3B8;
text-align: center;
}
.sync-actions-row {
display: flex;
gap: 8px;
}
.sync-error {
margin: 16px;
padding: 12px 16px;
background: #FEF2F2;
border-radius: 8px;
border: 1px solid #FECACA;
}
.sync-error-text {
font-size: 13px;
color: #DC2626;
}
.sync-loading {
display: flex;
justify-content: center;
padding: 48px 16px;
}
.sync-loading-text {
font-size: 14px;
color: #64748B;
}
.sync-result-card {
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
border-radius: 12px;
padding: 32px 16px;
margin-bottom: 16px;
}
.sync-result-icon {
font-size: 40px;
color: #22C55E;
margin-bottom: 8px;
}
.sync-result-title {
font-size: 18px;
font-weight: 600;
color: #1E293B;
margin-bottom: 4px;
}
.sync-result-count {
font-size: 13px;
color: #64748B;
}

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