康心伴Logo
康心伴WellAlly
Frontend Development

使用React和Web Bluetooth构建实时心率仪表板

5 分钟阅读

使用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构建实时心率监测仪表板的核心技术。这个应用可作为健康监测、健身追踪等项目的起点。

#

文章标签

React
Web Bluetooth
心率监测
实时数据
可穿戴设备
健康科技

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅