使用React和Web Bluetooth构建实时心率仪表板
随着可穿戴健康设备的普及,通过Web应用实时采集和展示生理数据成为可能。本文将教你如何使用React和Web Bluetooth API构建一个实时心率监测仪表板。
Web Bluetooth API简介
Web Bluetooth API允许Web应用与蓝牙低功耗(BLE)设备通信:
- 无需原生应用: 纯Web技术实现
- 双向通信: 支持读写和通知
- 设备广泛: 支持大多数心率带、智能手表
- 浏览器支持: Chrome、Edge等(需HTTPS)
局限性:
- 需要用户手动触发连接
- 某些浏览器不支持
- 需要HTTPS环境
- iOS Safari支持有限
项目架构
code
heart-rate-dashboard/
├── src/
│ ├── components/
│ │ ├── BluetoothConnector.tsx # 蓝牙连接组件
│ │ ├── HeartRateDisplay.tsx # 心率显示组件
│ │ ├── HeartRateChart.tsx # 心率图表组件
│ │ ├── StatsPanel.tsx # 统计面板
│ │ └── DeviceInfo.tsx # 设备信息组件
│ ├── services/
│ │ ├── bluetooth.service.ts # 蓝牙服务
│ │ └── storage.service.ts # 本地存储服务
│ ├── hooks/
│ │ ├── useHeartRate.ts # 心率数据钩子
│ │ ├── useBluetooth.ts # 蓝牙连接钩子
│ │ └── useMediaQuery.ts # 响应式钩子
│ ├── types/
│ │ └── bluetooth.ts # 类型定义
│ ├── utils/
│ │ ├── heartRate.ts # 心率计算工具
│ │ └── chart.ts # 图表配置
│ ├── App.tsx
│ └── index.tsx
├── public/
├── package.json
└── README.md
Code collapsed
1. 项目初始化
package.json
code
{
"name": "heart-rate-dashboard",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.8.0",
"date-fns": "^2.30.0",
"zustand": "^4.4.7",
"axios": "^1.6.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.8",
"tailwindcss": "^3.3.6"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}
Code collapsed
2. 类型定义
src/types/bluetooth.ts
code
// 蓝牙设备类型
export interface BluetoothDevice {
id: string;
name?: string;
gatt?: BluetoothRemoteGATTServer;
}
// 心率测量数据
export interface HeartRateMeasurement {
heartRate: number;
timestamp: number;
sensorContact?: boolean;
energyExpended?: number;
rrIntervals?: number[];
}
// 心率统计数据
export interface HeartRateStats {
current: number;
average: number;
min: number;
max: number;
resting?: number;
zones: HeartRateZones;
}
// 心率区间
export interface HeartRateZones {
resting: number;
fatBurn: number;
cardio: number;
peak: number;
}
// 连接状态
export type ConnectionStatus =
| 'disconnected'
| 'connecting'
| 'connected'
| 'disconnecting'
| 'error';
// 蓝牙服务配置
export interface BluetoothServiceConfig {
service: BluetoothServiceUUID;
characteristic: BluetoothCharacteristicUUID;
}
// 心率服务UUID
export const HEART_RATE_SERVICE: BluetoothServiceUUID =
'0000180d-0000-1000-8000-00805f9b34fb' as BluetoothServiceUUID;
export const HEART_RATE_MEASUREMENT: BluetoothCharacteristicUUID =
'00002a37-0000-1000-8000-00805f9b34fb' as BluetoothCharacteristicUUID;
export const BODY_SENSOR_LOCATION: BluetoothCharacteristicUUID =
'00002a38-0000-1000-8000-00805f9b34fb' as BluetoothCharacteristicUUID;
Code collapsed
3. 蓝牙服务实现
src/services/bluetooth.service.ts
code
import {
HEART_RATE_SERVICE,
HEART_RATE_MEASUREMENT,
BODY_SENSOR_LOCATION,
BluetoothDevice,
HeartRateMeasurement,
ConnectionStatus,
BluetoothServiceConfig,
} from '../types/bluetooth';
class BluetoothService {
private device: BluetoothDevice | null = null;
private server: BluetoothRemoteGATTServer | null = null;
private characteristic: BluetoothRemoteGATTCharacteristic | null = null;
private listener: ((data: HeartRateMeasurement) => void) | null = null;
/**
* 检查浏览器支持
*/
isSupported(): boolean {
return 'bluetooth' in navigator;
}
/**
* 请求设备并连接
*/
async connect(
onHeartRate: (data: HeartRateMeasurement) => void
): Promise<BluetoothDevice> {
if (!this.isSupported()) {
throw new Error('Web Bluetooth API不受支持');
}
try {
// 1. 请求设备
this.device = await navigator.bluetooth.requestDevice({
filters: [{ services: [HEART_RATE_SERVICE] }],
optionalServices: [HEART_RATE_SERVICE],
});
// 2. 连接GATT服务器
this.server = await this.device.gatt!.connect();
// 3. 设置断开监听
this.device.addEventListener('gattserverdisconnected', this.onDisconnect);
// 4. 获取心率服务
const service = await this.server.getPrimaryService(HEART_RATE_SERVICE);
// 5. 获取心率测量特征
this.characteristic = await service.getCharacteristic(
HEART_RATE_MEASUREMENT
);
// 6. 启动通知
await this.characteristic.startNotifications();
// 7. 设置数据监听器
this.listener = onHeartRate;
this.characteristic.addEventListener(
'characteristicvaluechanged',
this.onHeartRateChanged
);
// 8. 获取传感器位置
const locationCharacteristic = await service.getCharacteristic(
BODY_SENSOR_LOCATION
);
const location = await locationCharacteristic.readValue();
console.log('Sensor location:', this.parseSensorLocation(location));
return this.device;
} catch (error) {
console.error('Bluetooth连接失败:', error);
throw error;
}
}
/**
* 断开连接
*/
async disconnect(): Promise<void> {
if (this.characteristic) {
this.characteristic.removeEventListener(
'characteristicvaluechanged',
this.onHeartRateChanged
);
await this.characteristic.stopNotifications();
this.characteristic = null;
}
if (this.server && this.server.connected) {
await this.server.disconnect();
}
if (this.device) {
this.device.removeEventListener('gattserverdisconnected', this.onDisconnect);
this.device = null;
}
this.server = null;
this.listener = null;
}
/**
* 获取连接状态
*/
getConnectionStatus(): ConnectionStatus {
if (!this.device) return 'disconnected';
if (!this.server) return 'disconnected';
if (this.server.connected) return 'connected';
return 'disconnected';
}
/**
* 处理心率数据
*/
private onHeartRateChanged = (event: Event): void => {
const characteristic = event.target as BluetoothRemoteGATTCharacteristic;
const value = characteristic.value;
if (value && this.listener) {
const measurement = this.parseHeartRate(value);
this.listener(measurement);
}
};
/**
* 解析心率测量数据
*/
private parseHeartRate(data: DataView): HeartRateMeasurement {
const flags = data.getUint8(0);
// 心率值格式(8位或16位)
const is16Bit = (flags & 0x01) === 1;
// 传感器接触检测
const sensorContact = (flags & 0x06) !== 0;
const sensorContactSupported = (flags & 0x04) !== 0;
// 能量消耗
const energyExpendedPresent = (flags & 0x08) !== 0;
// RR间期
const rrIntervalsPresent = (flags & 0x10) !== 0;
let offset = 1;
// 解析心率值
const heartRate = is16Bit
? data.getUint16(offset, false)
: data.getUint8(offset);
offset += is16Bit ? 2 : 1;
// 解析能量消耗
let energyExpended: number | undefined;
if (energyExpendedPresent) {
energyExpended = data.getUint16(offset, false);
offset += 2;
}
// 解析RR间期
let rrIntervals: number[] | undefined;
if (rrIntervalsPresent) {
rrIntervals = [];
while (offset + 1 < data.byteLength) {
const rr = data.getUint16(offset, false);
rrIntervals.push(rr / 1024); // 转换为秒
offset += 2;
}
}
return {
heartRate,
timestamp: Date.now(),
sensorContact: sensorContactSupported ? sensorContact : undefined,
energyExpended,
rrIntervals,
};
}
/**
* 解析传感器位置
*/
private parseSensorLocation(data: DataView): string {
const location = data.getUint8(0);
const locations = [
'Other',
'Chest',
'Wrist',
'Finger',
'Hand',
'Ear Lobe',
'Foot',
];
return locations[location] || 'Unknown';
}
/**
* 处理断开连接
*/
private onDisconnect = (): void => {
console.log('设备已断开连接');
this.device = null;
this.server = null;
this.characteristic = null;
};
}
export const bluetoothService = new BluetoothService();
Code collapsed
4. 心率数据钩子
src/hooks/useHeartRate.ts
code
import { useState, useCallback, useRef } from 'react';
import { bluetoothService } from '../services/bluetooth.service';
import {
HeartRateMeasurement,
HeartRateStats,
ConnectionStatus,
HeartRateZones,
} from '../types/bluetooth';
import { calculateHeartRateZones, calculateStats } from '../utils/heartRate';
const MAX_HISTORY = 300; // 保留5分钟数据(假设每秒更新)
export function useHeartRate(maxAge?: number = 30) {
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [currentRate, setCurrentRate] = useState<number>(0);
const [stats, setStats] = useState<HeartRateStats>({
current: 0,
average: 0,
min: 0,
max: 0,
zones: calculateHeartRateZones(30), // 默认30岁
});
const historyRef = useRef<HeartRateMeasurement[]>([]);
const onHeartRateCallbackRef = useRef<((data: HeartRateMeasurement) => void) | null>(null);
/**
* 连接设备
*/
const connect = useCallback(async () => {
if (!bluetoothService.isSupported()) {
setStatus('error');
throw new Error('Web Bluetooth API不受支持');
}
setStatus('connecting');
try {
await bluetoothService.connect((data) => {
setCurrentRate(data.heartRate);
// 更新历史记录
historyRef.current.push(data);
if (historyRef.current.length > MAX_HISTORY) {
historyRef.current.shift();
}
// 计算统计数据
const recentData = maxAge
? historyRef.current.filter(
(d) => d.timestamp > Date.now() - maxAge * 1000
)
: historyRef.current;
const newStats = calculateStats(recentData);
setStats((prev) => ({
...prev,
...newStats,
}));
// 触发回调
onHeartRateCallbackRef.current?.(data);
});
setStatus('connected');
} catch (error) {
setStatus('error');
throw error;
}
}, [maxAge]);
/**
* 断开连接
*/
const disconnect = useCallback(async () => {
setStatus('disconnecting');
try {
await bluetoothService.disconnect();
setStatus('disconnected');
setCurrentRate(0);
} catch (error) {
setStatus('error');
throw error;
}
}, []);
/**
* 获取历史数据
*/
const getHistory = useCallback((seconds: number = 60) => {
const cutoff = Date.now() - seconds * 1000;
return historyRef.current.filter((d) => d.timestamp >= cutoff);
}, []);
/**
* 重置统计数据
*/
const reset = useCallback(() => {
historyRef.current = [];
setCurrentRate(0);
setStats({
current: 0,
average: 0,
min: 0,
max: 0,
zones: calculateHeartRateZones(30),
});
}, []);
/**
* 设置心率数据回调
*/
const onHeartRate = useCallback((callback: (data: HeartRateMeasurement) => void) => {
onHeartRateCallbackRef.current = callback;
}, []);
return {
status,
currentRate,
stats,
history: historyRef.current,
connect,
disconnect,
getHistory,
reset,
onHeartRate,
};
}
Code collapsed
5. 心率显示组件
src/components/HeartRateDisplay.tsx
code
import React from 'react';
import { HeartRateStats, ConnectionStatus } from '../types/bluetooth';
interface HeartRateDisplayProps {
currentRate: number;
stats: HeartRateStats;
status: ConnectionStatus;
}
export const HeartRateDisplay: React.FC<HeartRateDisplayProps> = ({
currentRate,
stats,
status,
}) => {
const getStatusText = (): string => {
switch (status) {
case 'connected':
return '已连接';
case 'connecting':
return '连接中...';
case 'disconnecting':
return '断开中...';
case 'error':
return '连接错误';
default:
return '未连接';
}
};
const getStatusColor = (): string => {
switch (status) {
case 'connected':
return 'text-green-500';
case 'connecting':
case 'disconnecting':
return 'text-yellow-500';
case 'error':
return 'text-red-500';
default:
return 'text-gray-400';
}
};
const getHeartRateColor = (hr: number): string => {
if (hr < stats.zones.resting) return 'text-blue-500';
if (hr < stats.zones.fatBurn) return 'text-green-500';
if (hr < stats.zones.cardio) return 'text-yellow-500';
if (hr < stats.zones.peak) return 'text-orange-500';
return 'text-red-500';
};
const getCurrentZone = (): { name: string; color: string } => {
if (currentRate < stats.zones.fatBurn) {
return { name: '静息区间', color: 'bg-blue-500' };
}
if (currentRate < stats.zones.cardio) {
return { name: '燃脂区间', color: 'bg-green-500' };
}
if (currentRate < stats.zones.peak) {
return { name: '心肺区间', color: 'bg-yellow-500' };
}
return { name: '峰值区间', color: 'bg-red-500' };
};
const zone = getCurrentZone();
return (
<div className="bg-white rounded-2xl shadow-lg p-8">
{/* 状态指示器 */}
<div className="flex items-center justify-between mb-6">
<span className={`text-sm font-medium ${getStatusColor()}`}>
{getStatusText()}
</span>
<div className="flex items-center space-x-2">
<div
className={`w-3 h-3 rounded-full ${
status === 'connected' ? 'bg-green-500 animate-pulse' : 'bg-gray-300'
}`}
/>
<span className="text-xs text-gray-500">实时监测</span>
</div>
</div>
{/* 当前心率 */}
<div className="text-center mb-8">
<div className="relative inline-block">
<span className={`text-8xl font-bold ${getHeartRateColor(currentRate)}`}>
{currentRate || '--'}
</span>
<span className="text-2xl text-gray-400 ml-2">BPM</span>
</div>
{/* 当前区间 */}
{status === 'connected' && (
<div className="mt-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm text-white ${zone.color}`}>
{zone.name}
</span>
</div>
)}
</div>
{/* 统计数据 */}
<div className="grid grid-cols-4 gap-4">
<StatItem label="平均" value={stats.average} unit="BPM" />
<StatItem label="最低" value={stats.min} unit="BPM" color="text-blue-500" />
<StatItem label="最高" value={stats.max} unit="BPM" color="text-red-500" />
<StatItem label="静息" value={stats.resting || '--'} unit="BPM" color="text-purple-500" />
</div>
{/* 心率区间指示 */}
<div className="mt-8">
<HeartRateZonesIndicator zones={stats.zones} currentRate={currentRate} />
</div>
</div>
);
};
const StatItem: React.FC<{
label: string;
value: number | string;
unit: string;
color?: string;
}> = ({ label, value, unit, color = 'text-gray-700' }) => (
<div className="text-center">
<div className={`text-2xl font-semibold ${color}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
<div className="text-xs text-gray-400">{unit}</div>
</div>
);
const HeartRateZonesIndicator: React.FC<{
zones: import('../types/bluetooth').HeartRateZones;
currentRate: number;
}> = ({ zones, currentRate }) => {
const calculateWidth = (max: number, min: number): number => {
const range = max - min;
const currentInRange = Math.max(min, Math.min(max, currentRate)) - min;
return range > 0 ? (currentInRange / range) * 100 : 0;
};
return (
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-500">
<span>静息</span>
<span>燃脂</span>
<span>心肺</span>
<span>峰值</span>
</div>
<div className="h-3 bg-gray-200 rounded-full overflow-hidden flex">
<div
className="bg-blue-500 transition-all duration-300"
style={{ width: `${calculateWidth(zones.fatBurn, 0)}%` }}
/>
<div
className="bg-green-500 transition-all duration-300"
style={{ width: `${calculateWidth(zones.cardio, zones.fatBurn)}%` }}
/>
<div
className="bg-yellow-500 transition-all duration-300"
style={{ width: `${calculateWidth(zones.peak, zones.cardio)}%` }}
/>
<div
className="bg-red-500 transition-all duration-300"
style={{ width: `${calculateWidth(220, zones.peak)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>{zones.resting}</span>
<span>{zones.fatBurn}</span>
<span>{zones.cardio}</span>
<span>{zones.peak}</span>
</div>
</div>
);
};
Code collapsed
6. 心率图表组件
src/components/HeartRateChart.tsx
code
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { HeartRateMeasurement, HeartRateZones } from '../types/bluetooth';
interface HeartRateChartProps {
data: HeartRateMeasurement[];
zones: HeartRateZones;
}
export const HeartRateChart: React.FC<HeartRateChartProps> = ({ data, zones }) => {
const chartData = data.map((d) => ({
time: new Date(d.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
heartRate: d.heartRate,
}));
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-3 rounded-lg shadow-lg border border-gray-200">
<p className="text-sm font-medium">{payload[0].payload.time}</p>
<p className="text-lg font-bold text-red-500">
{payload[0].value} BPM
</p>
</div>
);
}
return null;
};
return (
<div className="bg-white rounded-2xl shadow-lg p-6 h-96">
<h3 className="text-lg font-semibold mb-4">心率趋势</h3>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="time"
stroke="#9ca3af"
fontSize={12}
tickLine={false}
/>
<YAxis
stroke="#9ca3af"
fontSize={12}
tickLine={false}
domain={[0, 220]}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<ReferenceLine
y={zones.fatBurn}
stroke="#10b981"
strokeDasharray="3 3"
label={{ value: '燃脂', position: 'left' }}
/>
<ReferenceLine
y={zones.cardio}
stroke="#f59e0b"
strokeDasharray="3 3"
label={{ value: '心肺', position: 'left' }}
/>
<ReferenceLine
y={zones.peak}
stroke="#ef4444"
strokeDasharray="3 3"
label={{ value: '峰值', position: 'left' }}
/>
<Line
type="monotone"
dataKey="heartRate"
stroke="#ef4444"
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
};
Code collapsed
7. 蓝牙连接组件
src/components/BluetoothConnector.tsx
code
import React from 'react';
import { ConnectionStatus } from '../types/bluetooth';
interface BluetoothConnectorProps {
status: ConnectionStatus;
onConnect: () => void;
onDisconnect: () => void;
}
export const BluetoothConnector: React.FC<BluetoothConnectorProps> = ({
status,
onConnect,
onDisconnect,
}) => {
const isSupported = 'bluetooth' in navigator;
if (!isSupported) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center">
<svg
className="w-5 h-5 text-yellow-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span className="text-sm text-yellow-800">
您的浏览器不支持Web Bluetooth API。请使用Chrome或Edge浏览器。
</span>
</div>
</div>
);
}
return (
<div className="bg-white rounded-2xl shadow-lg p-6">
<h3 className="text-lg font-semibold mb-4">设备连接</h3>
<div className="space-y-4">
{/* 连接状态 */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center">
<StatusIcon status={status} />
<div className="ml-3">
<div className="text-sm font-medium text-gray-900">
{status === 'connected' ? '心率监测器已连接' : '心率监测器未连接'}
</div>
<div className="text-xs text-gray-500">
{status === 'connected'
? '正在接收实时心率数据'
: '点击按钮连接您的蓝牙心率带'}
</div>
</div>
</div>
<div>
{status === 'connected' ? (
<button
onClick={onDisconnect}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
断开连接
</button>
) : (
<button
onClick={onConnect}
disabled={status === 'connecting' || status === 'disconnecting'}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:bg-gray-400"
>
{status === 'connecting' ? '连接中...' : '连接设备'}
</button>
)}
</div>
</div>
{/* 使用说明 */}
<div className="text-xs text-gray-500 space-y-1">
<p>• 确保您的心率监测器已开启且在可发现模式</p>
<p>• 保持设备距离计算机10米以内</p>
<p>• 连接后请允许浏览器访问蓝牙设备</p>
</div>
</div>
</div>
);
};
const StatusIcon: React.FC<{ status: ConnectionStatus }> = ({ status }) => {
const icons = {
connected: (
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg
className="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
),
connecting: (
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<svg
className="w-6 h-6 text-yellow-600 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
),
disconnected: (
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-6 h-6 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
),
error: (
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<svg
className="w-6 h-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
),
disconnecting: (
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<svg
className="w-6 h-6 text-yellow-600 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
),
};
return icons[status] || icons.disconnected;
};
Code collapsed
8. 主应用组件
src/App.tsx
code
import React from 'react';
import { useHeartRate } from './hooks/useHeartRate';
import { BluetoothConnector } from './components/BluetoothConnector';
import { HeartRateDisplay } from './components/HeartRateDisplay';
import { HeartRateChart } from './components/HeartRateChart';
import { StatsPanel } from './components/StatsPanel';
function App() {
const { status, currentRate, stats, history, connect, disconnect } = useHeartRate(60);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50">
<div className="container mx-auto px-4 py-8">
{/* 头部 */}
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">
实时心率监测仪表板
</h1>
<p className="text-gray-600 mt-2">
使用Web Bluetooth API连接您的心率监测器
</p>
</header>
{/* 主要内容 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:连接控制和心率显示 */}
<div className="space-y-6">
<BluetoothConnector
status={status}
onConnect={connect}
onDisconnect={disconnect}
/>
<HeartRateDisplay
currentRate={currentRate}
stats={stats}
status={status}
/>
</div>
{/* 右侧:图表和统计 */}
<div className="lg:col-span-2 space-y-6">
<HeartRateChart data={history} zones={stats.zones} />
<StatsPanel history={history} />
</div>
</div>
{/* 页脚 */}
<footer className="mt-8 text-center text-sm text-gray-500">
<p>支持Polar H7/H10、Wahoo TICKR等标准蓝牙心率带</p>
<p className="mt-1">请使用Chrome、Edge或其他支持Web Bluetooth的浏览器</p>
</footer>
</div>
</div>
);
}
export default App;
Code collapsed
兼容性说明
支持的浏览器:
- Chrome 56+ (推荐)
- Edge 79+
- Opera 43+
不支持:
- Firefox (未默认启用)
- Safari (iOS/macOS)
支持的设备:
- Polar H7/H10/H9
- Wahoo TICKR
- Garmin心率带
- 其他符合BLE心率标准的设备
通过本教程,你已掌握使用React和Web Bluetooth API构建实时心率监测仪表板的核心技术。这个应用可作为健康监测、健身追踪等项目的起点。