فهرست منبع

Improve heart rate device reconnect flow

zhangyan 2 هفته پیش
والد
کامیت
0ccf7daf50

+ 133 - 32
miniprogram/engine/map/mapEngine.ts

@@ -1,6 +1,6 @@
 import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
 import { CompassHeadingController } from '../sensor/compassHeadingController'
-import { HeartRateController } from '../sensor/heartRateController'
+import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
 import { LocationController } from '../sensor/locationController'
 import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
 import { type MapRendererStats } from '../renderer/mapRenderer'
@@ -132,6 +132,14 @@ export interface MapEngineViewState {
   heartRateConnected: boolean
   heartRateStatusText: string
   heartRateDeviceText: string
+  heartRateScanText: string
+  heartRateDiscoveredDevices: Array<{
+    deviceId: string
+    name: string
+    rssiText: string
+    preferred: boolean
+    connected: boolean
+  }>
   gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
   gameModeText: string
   panelTimerText: string
@@ -227,6 +235,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'heartRateConnected',
   'heartRateStatusText',
   'heartRateDeviceText',
+  'heartRateScanText',
+  'heartRateDiscoveredDevices',
   'gameSessionStatus',
   'gameModeText',
   'panelTimerText',
@@ -612,37 +622,64 @@ export class MapEngine {
         this.setState(this.getLocationControllerViewPatch(), true)
       },
     })
-    this.heartRateController = new HeartRateController({
-      onHeartRate: (bpm) => {
-        this.telemetryRuntime.dispatch({
-          type: 'heart_rate_updated',
-          at: Date.now(),
+      this.heartRateController = new HeartRateController({
+        onHeartRate: (bpm) => {
+          this.telemetryRuntime.dispatch({
+            type: 'heart_rate_updated',
+            at: Date.now(),
           bpm,
         })
         this.syncSessionTimerText()
-      },
-      onStatus: (message) => {
-        this.setState({
-          heartRateStatusText: message,
-          heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
-        }, true)
-      },
-      onError: (message) => {
-        this.setState({
-          heartRateConnected: false,
-          heartRateStatusText: message,
-          heartRateDeviceText: '--',
-          statusText: `${message} (${this.buildVersion})`,
-        }, true)
-      },
-      onConnectionChange: (connected, deviceName) => {
-        this.setState({
-          heartRateConnected: connected,
-          heartRateDeviceText: deviceName || '--',
-          heartRateStatusText: connected ? '心率带已连接' : '心率带未连接',
-        }, true)
-      },
-    })
+        },
+        onStatus: (message) => {
+          const deviceName = this.heartRateController.currentDeviceName
+            || (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
+            || '--'
+          this.setState({
+            heartRateStatusText: message,
+            heartRateDeviceText: deviceName,
+            heartRateScanText: this.getHeartRateScanText(),
+          }, true)
+        },
+        onError: (message) => {
+          this.clearHeartRateSignal()
+          const deviceName = this.heartRateController.reconnecting
+            ? (this.heartRateController.lastDeviceName || '--')
+            : '--'
+          this.setState({
+            heartRateConnected: false,
+            heartRateStatusText: message,
+            heartRateDeviceText: deviceName,
+            heartRateScanText: this.getHeartRateScanText(),
+            statusText: `${message} (${this.buildVersion})`,
+          }, true)
+        },
+        onConnectionChange: (connected, deviceName) => {
+          if (!connected) {
+            this.clearHeartRateSignal()
+          }
+          const resolvedDeviceName = connected
+            ? (deviceName || '--')
+            : (this.heartRateController.reconnecting
+              ? (this.heartRateController.lastDeviceName || '--')
+              : '--')
+          this.setState({
+            heartRateConnected: connected,
+            heartRateDeviceText: resolvedDeviceName,
+            heartRateStatusText: connected
+              ? '心率带已连接'
+              : (this.heartRateController.reconnecting ? '心率带自动重连中' : '心率带未连接'),
+            heartRateScanText: this.getHeartRateScanText(),
+            heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
+          }, true)
+        },
+        onDeviceListChange: (devices) => {
+          this.setState({
+            heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
+            heartRateScanText: this.getHeartRateScanText(),
+          }, true)
+        },
+      })
     this.feedbackDirector = new FeedbackDirector({
       showPunchFeedback: (text, tone, motionClass) => {
         this.showPunchFeedback(text, tone, motionClass)
@@ -745,9 +782,11 @@ export class MapEngine {
       mockCoordText: '--',
       mockSpeedText: '--',
       gpsCoordText: '--',
-      heartRateConnected: false,
-      heartRateStatusText: '心率带未连接',
-      heartRateDeviceText: '--',
+        heartRateConnected: false,
+        heartRateStatusText: '心率带未连接',
+        heartRateDeviceText: '--',
+        heartRateScanText: '未扫描',
+        heartRateDiscoveredDevices: [],
       panelTimerText: '00:00:00',
       panelMileageText: '0m',
       panelActionTagText: '目标',
@@ -848,6 +887,14 @@ export class MapEngine {
     this.mounted = false
   }
 
+  handleAppShow(): void {
+    this.feedbackDirector.setAppAudioMode('foreground')
+  }
+
+  handleAppHide(): void {
+    this.feedbackDirector.setAppAudioMode('foreground')
+  }
+
 
   clearGameRuntime(): void {
     this.gameRuntime.clear()
@@ -858,6 +905,15 @@ export class MapEngine {
     this.setCourseHeading(null)
   }
 
+  clearHeartRateSignal(): void {
+    this.telemetryRuntime.dispatch({
+      type: 'heart_rate_updated',
+      at: Date.now(),
+      bpm: null,
+    })
+    this.syncSessionTimerText()
+  }
+
   clearFinishedTestOverlay(): void {
     this.currentGpsPoint = null
     this.currentGpsTrack = []
@@ -1386,6 +1442,18 @@ export class MapEngine {
     this.heartRateController.disconnect()
   }
 
+  handleConnectHeartRateDevice(deviceId: string): void {
+    this.heartRateController.connectToDiscoveredDevice(deviceId)
+  }
+
+  handleClearPreferredHeartRateDevice(): void {
+    this.heartRateController.clearPreferredDevice()
+    this.setState({
+      heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
+      heartRateScanText: this.getHeartRateScanText(),
+    }, true)
+  }
+
   handleDebugHeartRateTone(tone: HeartRateTone): void {
     const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
     this.telemetryRuntime.dispatch({
@@ -1407,9 +1475,42 @@ export class MapEngine {
     })
     this.setState({
       heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
+      heartRateScanText: this.getHeartRateScanText(),
     }, true)
     this.syncSessionTimerText()
   }
+
+  formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> {
+    return devices.map((device) => ({
+      deviceId: device.deviceId,
+      name: device.name,
+      rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`,
+      preferred: device.isPreferred,
+      connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected,
+    }))
+  }
+
+  getHeartRateScanText(): string {
+    if (this.heartRateController.connected) {
+      return '已连接'
+    }
+
+    if (this.heartRateController.connecting) {
+      return '连接中'
+    }
+
+    if (this.heartRateController.disconnecting) {
+      return '断开中'
+    }
+
+    if (this.heartRateController.scanning) {
+      return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)'
+    }
+
+    return this.heartRateController.discoveredDevices.length
+      ? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备`
+      : '未扫描'
+  }
   setStage(rect: MapEngineStageRect): void {
     this.previewScale = 1
     this.previewOriginX = rect.width / 2

+ 368 - 14
miniprogram/engine/sensor/heartRateController.ts

@@ -3,18 +3,30 @@ export interface HeartRateControllerCallbacks {
   onStatus: (message: string) => void
   onError: (message: string) => void
   onConnectionChange: (connected: boolean, deviceName: string | null) => void
+  onDeviceListChange: (devices: HeartRateDiscoveredDevice[]) => void
+}
+
+export interface HeartRateDiscoveredDevice {
+  deviceId: string
+  name: string
+  rssi: number | null
+  lastSeenAt: number
+  isPreferred: boolean
 }
 
 type BluetoothDeviceLike = {
   deviceId?: string
   name?: string
   localName?: string
+  RSSI?: number
   advertisServiceUUIDs?: string[]
 }
 
 const HEART_RATE_SERVICE_UUID = '180D'
 const HEART_RATE_MEASUREMENT_UUID = '2A37'
 const DISCOVERY_TIMEOUT_MS = 12000
+const DEVICE_STALE_MS = 15000
+const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
 
 function normalizeUuid(uuid: string | undefined | null): string {
   return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
@@ -80,9 +92,18 @@ export class HeartRateController {
   connected: boolean
   currentDeviceId: string | null
   currentDeviceName: string | null
+  discoveredDevices: HeartRateDiscoveredDevice[]
+  lastDeviceId: string | null
+  lastDeviceName: string | null
+  manualDisconnect: boolean
+  reconnecting: boolean
+  disconnecting: boolean
+  disconnectingDeviceId: string | null
+  autoConnectDeviceId: string | null
   measurementServiceId: string | null
   measurementCharacteristicId: string | null
   discoveryTimer: number
+  reconnectTimer: number
   deviceFoundHandler: ((result: any) => void) | null
   characteristicHandler: ((result: any) => void) | null
   connectionStateHandler: ((result: any) => void) | null
@@ -94,12 +115,22 @@ export class HeartRateController {
     this.connected = false
     this.currentDeviceId = null
     this.currentDeviceName = null
+    this.discoveredDevices = []
+    this.lastDeviceId = null
+    this.lastDeviceName = null
+    this.manualDisconnect = false
+    this.reconnecting = false
+    this.disconnecting = false
+    this.disconnectingDeviceId = null
+    this.autoConnectDeviceId = null
     this.measurementServiceId = null
     this.measurementCharacteristicId = null
     this.discoveryTimer = 0
+    this.reconnectTimer = 0
     this.deviceFoundHandler = null
     this.characteristicHandler = null
     this.connectionStateHandler = null
+    this.restorePreferredDevice()
   }
 
   startScanAndConnect(): void {
@@ -108,40 +139,55 @@ export class HeartRateController {
       return
     }
 
+    if (this.disconnecting) {
+      this.callbacks.onStatus('心率带断开中,请稍后再试')
+      return
+    }
+
     if (this.scanning || this.connecting) {
       this.callbacks.onStatus('心率带连接进行中')
       return
     }
 
-    const wxAny = wx as any
-    wxAny.openBluetoothAdapter({
-      success: () => {
+    this.manualDisconnect = false
+    this.reconnecting = false
+    this.clearReconnectTimer()
+
+    this.withFreshBluetoothAdapter(() => {
+      if (this.lastDeviceId) {
+        this.callbacks.onStatus(`正在扫描并优先连接 ${this.lastDeviceName || '心率带'}`)
         this.beginDiscovery()
-      },
-      fail: (error: any) => {
-        const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
-        this.callbacks.onError(`蓝牙不可用: ${message}`)
-      },
+        return
+      }
+
+      this.beginDiscovery()
     })
   }
 
   disconnect(): void {
     this.clearDiscoveryTimer()
+    this.clearReconnectTimer()
     this.stopDiscovery()
 
     const deviceId = this.currentDeviceId
     this.connecting = false
+    this.reconnecting = false
+    this.manualDisconnect = true
+    this.disconnecting = true
 
     if (!deviceId) {
+      this.disconnecting = false
       this.clearConnectionState()
       this.callbacks.onStatus('心率带未连接')
       return
     }
 
+    this.disconnectingDeviceId = deviceId
     const wxAny = wx as any
     wxAny.closeBLEConnection({
       deviceId,
       complete: () => {
+        this.disconnecting = false
         this.clearConnectionState()
         this.callbacks.onStatus('心率带已断开')
       },
@@ -150,6 +196,7 @@ export class HeartRateController {
 
   destroy(): void {
     this.clearDiscoveryTimer()
+    this.clearReconnectTimer()
     this.stopDiscovery()
     this.detachListeners()
 
@@ -173,14 +220,20 @@ export class HeartRateController {
   }
 
   beginDiscovery(): void {
+    if (this.scanning || this.connecting || this.connected) {
+      return
+    }
+
     this.bindListeners()
+    this.autoConnectDeviceId = this.lastDeviceId
     const wxAny = wx as any
     wxAny.startBluetoothDevicesDiscovery({
       allowDuplicatesKey: false,
       services: [HEART_RATE_SERVICE_UUID],
       success: () => {
         this.scanning = true
-        this.callbacks.onStatus('正在扫描心率带')
+        this.pruneDiscoveredDevices()
+        this.callbacks.onStatus(this.autoConnectDeviceId ? '正在扫描心率带并等待自动连接' : '正在扫描心率带,请选择设备')
         this.clearDiscoveryTimer()
         this.discoveryTimer = setTimeout(() => {
           this.discoveryTimer = 0
@@ -189,7 +242,7 @@ export class HeartRateController {
           }
 
           this.stopDiscovery()
-          this.callbacks.onError('未发现可连接的心率带')
+          this.callbacks.onError(this.discoveredDevices.length ? '已发现心率带,请从列表选择连接' : '未发现可连接的心率带')
         }, DISCOVERY_TIMEOUT_MS) as unknown as number
       },
       fail: (error: any) => {
@@ -207,6 +260,7 @@ export class HeartRateController {
     }
 
     this.scanning = false
+    this.autoConnectDeviceId = null
     const wxAny = wx as any
     wxAny.stopBluetoothDevicesDiscovery({
       complete: () => {},
@@ -224,8 +278,13 @@ export class HeartRateController {
             ? [result]
             : []
 
-        const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device))
-        if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) {
+        this.mergeDiscoveredDevices(devices)
+        if (!this.scanning || this.connecting || this.connected) {
+          return
+        }
+
+        const targetDevice = this.selectTargetDevice(devices)
+        if (!targetDevice || !targetDevice.deviceId) {
           return
         }
 
@@ -267,7 +326,7 @@ export class HeartRateController {
 
     if (!this.connectionStateHandler) {
       this.connectionStateHandler = (result: any) => {
-        if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
+        if (!result || !result.deviceId) {
           return
         }
 
@@ -275,8 +334,20 @@ export class HeartRateController {
           return
         }
 
+        if (this.disconnectingDeviceId && result.deviceId === this.disconnectingDeviceId) {
+          this.disconnectingDeviceId = null
+          return
+        }
+
+        if (!this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
+          return
+        }
+
+        const disconnectedDeviceId = this.currentDeviceId
+        const disconnectedDeviceName = this.currentDeviceName
         this.clearConnectionState()
         this.callbacks.onStatus('心率带连接已断开')
+        this.scheduleAutoReconnect(disconnectedDeviceId, disconnectedDeviceName)
       }
 
       if (typeof wxAny.onBLEConnectionStateChange === 'function') {
@@ -303,8 +374,13 @@ export class HeartRateController {
     this.connectionStateHandler = null
   }
 
-  connectToDevice(deviceId: string, deviceName: string): void {
+  connectToDevice(deviceId: string, deviceName: string, fallbackToDiscovery: boolean = false): void {
     this.connecting = true
+    this.reconnecting = false
+    this.disconnecting = false
+    this.manualDisconnect = false
+    this.disconnectingDeviceId = null
+    this.autoConnectDeviceId = deviceId
     this.currentDeviceId = deviceId
     this.currentDeviceName = deviceName
     this.callbacks.onStatus(`正在连接 ${deviceName}`)
@@ -319,6 +395,11 @@ export class HeartRateController {
       fail: (error: any) => {
         const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
         this.clearConnectionState()
+        if (fallbackToDiscovery && !this.manualDisconnect) {
+          this.callbacks.onStatus(`直连失败,转入扫描: ${deviceName}`)
+          this.beginDiscovery()
+          return
+        }
         this.callbacks.onError(`连接心率带失败: ${message}`)
       },
     })
@@ -364,6 +445,15 @@ export class HeartRateController {
               success: () => {
                 this.connecting = false
                 this.connected = true
+                this.lastDeviceId = deviceId
+                this.lastDeviceName = deviceName
+                this.persistPreferredDevice()
+                this.manualDisconnect = false
+                this.reconnecting = false
+                this.disconnectingDeviceId = null
+                this.autoConnectDeviceId = deviceId
+                this.refreshPreferredFlags()
+                this.clearReconnectTimer()
                 this.callbacks.onConnectionChange(true, deviceName)
                 this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
               },
@@ -393,6 +483,7 @@ export class HeartRateController {
       complete: () => {
         this.clearConnectionState()
         this.callbacks.onError(message)
+        this.scheduleAutoReconnect(this.lastDeviceId, this.lastDeviceName)
       },
     })
   }
@@ -402,6 +493,9 @@ export class HeartRateController {
     this.scanning = false
     this.connecting = false
     this.connected = false
+    this.disconnecting = false
+    this.disconnectingDeviceId = null
+    this.autoConnectDeviceId = null
     this.currentDeviceId = null
     this.measurementServiceId = null
     this.measurementCharacteristicId = null
@@ -418,4 +512,264 @@ export class HeartRateController {
       this.discoveryTimer = 0
     }
   }
+
+  clearReconnectTimer(): void {
+    if (this.reconnectTimer) {
+      clearTimeout(this.reconnectTimer)
+      this.reconnectTimer = 0
+    }
+  }
+
+  selectTargetDevice(devices: BluetoothDeviceLike[]): BluetoothDeviceLike | null {
+    if (!Array.isArray(devices) || !devices.length) {
+      return null
+    }
+
+    if (this.autoConnectDeviceId) {
+      const rememberedDevice = devices.find((device) => device && device.deviceId === this.autoConnectDeviceId)
+      if (rememberedDevice && isHeartRateDevice(rememberedDevice)) {
+        return rememberedDevice
+      }
+
+      return null
+    }
+
+    return null
+  }
+
+  scheduleAutoReconnect(deviceId: string | null, deviceName: string | null): void {
+    if (this.manualDisconnect || !deviceId || this.connected || this.connecting || this.scanning) {
+      return
+    }
+
+    this.lastDeviceId = deviceId
+    this.lastDeviceName = deviceName || this.lastDeviceName
+    this.clearReconnectTimer()
+    this.reconnecting = true
+    this.callbacks.onStatus(`心率带已断开,等待自动重连: ${this.lastDeviceName || '设备'}`)
+    this.reconnectTimer = setTimeout(() => {
+      this.reconnectTimer = 0
+      if (this.manualDisconnect || this.connected || this.connecting || this.scanning) {
+        return
+      }
+
+      const rememberedDeviceId = this.lastDeviceId
+      const rememberedDeviceName = this.lastDeviceName || '心率带'
+      this.reconnecting = false
+        this.withFreshBluetoothAdapter(() => {
+          if (rememberedDeviceId) {
+            this.callbacks.onStatus(`正在自动重连 ${rememberedDeviceName}`)
+            this.resetConnectionAndConnect(rememberedDeviceId, rememberedDeviceName, true)
+            return
+          }
+
+          this.beginDiscovery()
+        })
+      }, 600) as unknown as number
+  }
+
+  resetConnectionAndConnect(deviceId: string, deviceName: string, fallbackToDiscovery: boolean): void {
+    const wxAny = wx as any
+    this.disconnectingDeviceId = deviceId
+    wxAny.closeBLEConnection({
+      deviceId,
+      complete: () => {
+        if (this.disconnectingDeviceId === deviceId) {
+          this.disconnectingDeviceId = null
+        }
+
+        setTimeout(() => {
+          if (this.connected || this.connecting || this.scanning || this.disconnecting) {
+            return
+          }
+
+          this.connectToDevice(deviceId, deviceName, fallbackToDiscovery)
+        }, 320)
+      },
+    })
+  }
+
+  connectToDiscoveredDevice(deviceId: string): void {
+    if (!deviceId) {
+      return
+    }
+
+    if (this.disconnecting) {
+      this.callbacks.onStatus('心率带断开中,请稍后再试')
+      return
+    }
+
+    const targetDevice = this.discoveredDevices.find((device) => device.deviceId === deviceId)
+    const deviceName = targetDevice ? targetDevice.name : (this.lastDeviceId === deviceId ? (this.lastDeviceName || '心率带') : '心率带')
+    this.lastDeviceId = deviceId
+    this.lastDeviceName = deviceName
+    this.refreshPreferredFlags()
+    this.stopDiscovery()
+
+    this.withFreshBluetoothAdapter(() => {
+      this.callbacks.onStatus(`正在连接 ${deviceName}`)
+      this.resetConnectionAndConnect(deviceId, deviceName, true)
+    })
+  }
+
+  clearPreferredDevice(): void {
+    this.lastDeviceId = null
+    this.lastDeviceName = null
+    this.autoConnectDeviceId = null
+    this.removePreferredDevice()
+    this.refreshPreferredFlags()
+    this.callbacks.onStatus('已清除首选心率带')
+  }
+
+  mergeDiscoveredDevices(devices: BluetoothDeviceLike[]): void {
+    if (!Array.isArray(devices) || !devices.length) {
+      return
+    }
+
+    const now = Date.now()
+    let changed = false
+    const nextDevices = [...this.discoveredDevices]
+    for (const rawDevice of devices) {
+      if (!rawDevice || !rawDevice.deviceId || !isHeartRateDevice(rawDevice)) {
+        continue
+      }
+
+      const name = getDeviceDisplayName(rawDevice)
+      const rssi = typeof rawDevice.RSSI === 'number' && Number.isFinite(rawDevice.RSSI) ? rawDevice.RSSI : null
+      const existingIndex = nextDevices.findIndex((item) => item.deviceId === rawDevice.deviceId)
+      const nextDevice: HeartRateDiscoveredDevice = {
+        deviceId: rawDevice.deviceId,
+        name,
+        rssi,
+        lastSeenAt: now,
+        isPreferred: rawDevice.deviceId === this.lastDeviceId,
+      }
+
+      if (existingIndex >= 0) {
+        nextDevices[existingIndex] = nextDevice
+      } else {
+        nextDevices.push(nextDevice)
+      }
+      changed = true
+    }
+
+    if (!changed) {
+      return
+    }
+
+    this.discoveredDevices = this.sortDiscoveredDevices(nextDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS))
+    this.callbacks.onDeviceListChange([...this.discoveredDevices])
+  }
+
+  pruneDiscoveredDevices(): void {
+    const now = Date.now()
+    const nextDevices = this.sortDiscoveredDevices(
+      this.discoveredDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS),
+    )
+    const changed = nextDevices.length !== this.discoveredDevices.length
+      || nextDevices.some((device, index) => {
+        const previous = this.discoveredDevices[index]
+        return !previous || previous.deviceId !== device.deviceId || previous.isPreferred !== device.isPreferred || previous.rssi !== device.rssi
+      })
+
+    this.discoveredDevices = nextDevices
+    if (changed) {
+      this.callbacks.onDeviceListChange([...this.discoveredDevices])
+    }
+  }
+
+  refreshPreferredFlags(): void {
+    if (!this.discoveredDevices.length) {
+      this.callbacks.onDeviceListChange([])
+      return
+    }
+
+    this.discoveredDevices = this.sortDiscoveredDevices(
+      this.discoveredDevices.map((device) => ({
+        ...device,
+        isPreferred: !!this.lastDeviceId && device.deviceId === this.lastDeviceId,
+      })),
+    )
+    this.callbacks.onDeviceListChange([...this.discoveredDevices])
+  }
+
+  sortDiscoveredDevices(devices: HeartRateDiscoveredDevice[]): HeartRateDiscoveredDevice[] {
+    return [...devices].sort((a, b) => {
+      if (a.isPreferred !== b.isPreferred) {
+        return a.isPreferred ? -1 : 1
+      }
+
+      const aRssi = a.rssi === null ? -999 : a.rssi
+      const bRssi = b.rssi === null ? -999 : b.rssi
+      if (aRssi !== bRssi) {
+        return bRssi - aRssi
+      }
+
+      return b.lastSeenAt - a.lastSeenAt
+    })
+  }
+
+  withFreshBluetoothAdapter(onReady: () => void): void {
+    const wxAny = wx as any
+    const openAdapter = () => {
+      wxAny.openBluetoothAdapter({
+        success: () => {
+          onReady()
+        },
+        fail: (error: any) => {
+          const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
+          this.callbacks.onError(`蓝牙不可用: ${message}`)
+        },
+      })
+    }
+
+    if (typeof wxAny.closeBluetoothAdapter !== 'function') {
+      openAdapter()
+      return
+    }
+
+    wxAny.closeBluetoothAdapter({
+      complete: () => {
+        setTimeout(() => {
+          openAdapter()
+        }, 180)
+      },
+    })
+  }
+
+  restorePreferredDevice(): void {
+    try {
+      const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
+      if (!stored || typeof stored !== 'object') {
+        return
+      }
+
+      const normalized = stored as { deviceId?: unknown; name?: unknown }
+      if (typeof normalized.deviceId !== 'string' || !normalized.deviceId) {
+        return
+      }
+
+      this.lastDeviceId = normalized.deviceId
+      this.lastDeviceName = typeof normalized.name === 'string' && normalized.name ? normalized.name : '心率带'
+    } catch {}
+  }
+
+  persistPreferredDevice(): void {
+    if (!this.lastDeviceId) {
+      return
+    }
+
+    try {
+      wx.setStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY, {
+        deviceId: this.lastDeviceId,
+        name: this.lastDeviceName || '心率带',
+      })
+    } catch {}
+  }
+
+  removePreferredDevice(): void {
+    try {
+      wx.removeStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
+    } catch {}
+  }
 }

+ 20 - 0
miniprogram/game/audio/audioConfig.ts

@@ -13,12 +13,14 @@ export interface AudioCueConfig {
   volume: number
   loop: boolean
   loopGapMs: number
+  backgroundMode: 'disabled' | 'guidance'
 }
 
 export interface GameAudioConfig {
   enabled: boolean
   masterVolume: number
   obeyMuteSwitch: boolean
+  backgroundAudioEnabled: boolean
   approachDistanceMeters: number
   cues: Record<AudioCueKey, AudioCueConfig>
 }
@@ -28,12 +30,14 @@ export interface PartialAudioCueConfig {
   volume?: number
   loop?: boolean
   loopGapMs?: number
+  backgroundMode?: 'disabled' | 'guidance'
 }
 
 export interface GameAudioConfigOverrides {
   enabled?: boolean
   masterVolume?: number
   obeyMuteSwitch?: boolean
+  backgroundAudioEnabled?: boolean
   approachDistanceMeters?: number
   cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
 }
@@ -42,6 +46,7 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
   enabled: true,
   masterVolume: 1,
   obeyMuteSwitch: true,
+  backgroundAudioEnabled: true,
   approachDistanceMeters: 20,
   cues: {
     session_started: {
@@ -49,48 +54,56 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
       volume: 0.78,
       loop: false,
       loopGapMs: 0,
+      backgroundMode: 'disabled',
     },
     'control_completed:start': {
       src: '/assets/sounds/start-complete.wav',
       volume: 0.84,
       loop: false,
       loopGapMs: 0,
+      backgroundMode: 'disabled',
     },
     'control_completed:control': {
       src: '/assets/sounds/control-complete.wav',
       volume: 0.8,
       loop: false,
       loopGapMs: 0,
+      backgroundMode: 'disabled',
     },
     'control_completed:finish': {
       src: '/assets/sounds/finish-complete.wav',
       volume: 0.92,
       loop: false,
       loopGapMs: 0,
+      backgroundMode: 'disabled',
     },
     'punch_feedback:warning': {
       src: '/assets/sounds/warning.wav',
       volume: 0.72,
       loop: false,
       loopGapMs: 0,
+      backgroundMode: 'disabled',
     },
     'guidance:searching': {
       src: '/assets/sounds/guidance-searching.wav',
       volume: 0.48,
       loop: true,
       loopGapMs: 1800,
+      backgroundMode: 'guidance',
     },
     'guidance:approaching': {
       src: '/assets/sounds/guidance-approaching.wav',
       volume: 0.58,
       loop: true,
       loopGapMs: 950,
+      backgroundMode: 'guidance',
     },
     'guidance:ready': {
       src: '/assets/sounds/guidance-ready.wav',
       volume: 0.68,
       loop: true,
       loopGapMs: 650,
+      backgroundMode: 'guidance',
     },
   },
 }
@@ -143,6 +156,10 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
       if (cue.loopGapMs !== undefined) {
         cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs)
       }
+
+      if (cue.backgroundMode === 'disabled' || cue.backgroundMode === 'guidance') {
+        cues[key].backgroundMode = cue.backgroundMode
+      }
     }
   }
 
@@ -150,6 +167,9 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
     enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled,
     masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
     obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch,
+    backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined
+      ? !!overrides.backgroundAudioEnabled
+      : true,
     approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
     cues,
   }

+ 151 - 1
miniprogram/game/audio/soundDirector.ts

@@ -6,14 +6,20 @@ export class SoundDirector {
   config: GameAudioConfig
   contexts: Partial<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
   loopTimers: Partial<Record<AudioCueKey, number>>
+  backgroundLoopTimer: number
   activeGuidanceCue: AudioCueKey | null
+  backgroundManager: WechatMiniprogram.BackgroundAudioManager | null
+  appAudioMode: 'foreground' | 'background'
 
   constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) {
     this.enabled = true
     this.config = config
     this.contexts = {}
     this.loopTimers = {}
+    this.backgroundLoopTimer = 0
     this.activeGuidanceCue = null
+    this.backgroundManager = null
+    this.appAudioMode = 'foreground'
   }
 
   configure(config: GameAudioConfig): void {
@@ -34,6 +40,7 @@ export class SoundDirector {
       }
     }
     this.loopTimers = {}
+    this.clearBackgroundLoopTimer()
 
     const keys = Object.keys(this.contexts) as AudioCueKey[]
     for (const key of keys) {
@@ -46,6 +53,7 @@ export class SoundDirector {
     }
     this.contexts = {}
     this.activeGuidanceCue = null
+    this.stopBackgroundGuidance()
   }
 
   destroy(): void {
@@ -108,7 +116,43 @@ export class SoundDirector {
     }
   }
 
+  setAppAudioMode(mode: 'foreground' | 'background'): void {
+    if (this.appAudioMode === mode) {
+      return
+    }
+
+    this.appAudioMode = mode
+    const activeGuidanceCue = this.activeGuidanceCue
+    if (!activeGuidanceCue) {
+      this.stopBackgroundGuidance()
+      return
+    }
+
+    if (mode === 'background') {
+      this.stopForegroundCue(activeGuidanceCue)
+      this.startBackgroundGuidance(activeGuidanceCue)
+      return
+    }
+
+    this.stopBackgroundGuidance()
+    this.playForeground(activeGuidanceCue)
+  }
+
   play(key: AudioCueKey): void {
+    if (this.appAudioMode === 'background') {
+      const cue = this.config.cues[key]
+      if (!cue || cue.backgroundMode !== 'guidance' || !this.isGuidanceCue(key)) {
+        return
+      }
+
+      this.startBackgroundGuidance(key)
+      return
+    }
+
+    this.playForeground(key)
+  }
+
+  playForeground(key: AudioCueKey): void {
     const cue = this.config.cues[key]
     if (!cue || !cue.src) {
       return
@@ -132,11 +176,17 @@ export class SoundDirector {
 
     this.stopGuidanceLoop()
     this.activeGuidanceCue = key
-    this.play(key)
+    if (this.appAudioMode === 'background') {
+      this.startBackgroundGuidance(key)
+      return
+    }
+
+    this.playForeground(key)
   }
 
   stopGuidanceLoop(): void {
     if (!this.activeGuidanceCue) {
+      this.stopBackgroundGuidance()
       return
     }
 
@@ -148,6 +198,7 @@ export class SoundDirector {
         context.seek(0)
       }
     }
+    this.stopBackgroundGuidance()
     this.activeGuidanceCue = null
   }
 
@@ -175,6 +226,105 @@ export class SoundDirector {
     }, cue.loopGapMs) as unknown as number
   }
 
+  handleBackgroundCueEnded(): void {
+    const key = this.activeGuidanceCue
+    if (!key || !this.enabled || !this.config.enabled || this.appAudioMode !== 'background') {
+      return
+    }
+
+    const cue = this.config.cues[key]
+    if (!cue || !cue.loop) {
+      return
+    }
+
+    this.clearBackgroundLoopTimer()
+    this.backgroundLoopTimer = setTimeout(() => {
+      this.backgroundLoopTimer = 0
+      if (this.activeGuidanceCue === key && this.appAudioMode === 'background' && this.enabled && this.config.enabled) {
+        this.playBackgroundCue(key)
+      }
+    }, cue.loopGapMs) as unknown as number
+  }
+
+  clearBackgroundLoopTimer(): void {
+    if (this.backgroundLoopTimer) {
+      clearTimeout(this.backgroundLoopTimer)
+      this.backgroundLoopTimer = 0
+    }
+  }
+
+  stopForegroundCue(key: AudioCueKey): void {
+    this.clearLoopTimer(key)
+    const context = this.contexts[key]
+    if (!context) {
+      return
+    }
+    context.stop()
+    if (typeof context.seek === 'function') {
+      context.seek(0)
+    }
+  }
+
+  isGuidanceCue(key: AudioCueKey): boolean {
+    return key === 'guidance:searching'
+      || key === 'guidance:approaching'
+      || key === 'guidance:ready'
+  }
+
+  startBackgroundGuidance(key: AudioCueKey): void {
+    if (!this.enabled || !this.config.enabled || !this.config.backgroundAudioEnabled) {
+      return
+    }
+
+    const cue = this.config.cues[key]
+    if (!cue || cue.backgroundMode !== 'guidance' || !cue.src) {
+      return
+    }
+
+    this.playBackgroundCue(key)
+  }
+
+  playBackgroundCue(key: AudioCueKey): void {
+    const cue = this.config.cues[key]
+    if (!cue || !cue.src) {
+      return
+    }
+
+    const manager = this.getBackgroundManager()
+    this.clearBackgroundLoopTimer()
+    manager.stop()
+    manager.title = 'ColorMapRun 引导音'
+    manager.epname = 'ColorMapRun'
+    manager.singer = 'ColorMapRun'
+    manager.coverImgUrl = ''
+    manager.src = cue.src
+    manager.play()
+  }
+
+  stopBackgroundGuidance(): void {
+    this.clearBackgroundLoopTimer()
+    if (!this.backgroundManager) {
+      return
+    }
+
+    this.backgroundManager.stop()
+  }
+
+  getBackgroundManager(): WechatMiniprogram.BackgroundAudioManager {
+    if (this.backgroundManager) {
+      return this.backgroundManager
+    }
+
+    const manager = wx.getBackgroundAudioManager()
+    if (typeof manager.onEnded === 'function') {
+      manager.onEnded(() => {
+        this.handleBackgroundCueEnded()
+      })
+    }
+    this.backgroundManager = manager
+    return manager
+  }
+
   getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext {
     const existing = this.contexts[key]
     if (existing) {

+ 4 - 0
miniprogram/game/feedback/feedbackDirector.ts

@@ -45,6 +45,10 @@ export class FeedbackDirector {
     this.uiEffectDirector.destroy()
   }
 
+  setAppAudioMode(mode: 'foreground' | 'background'): void {
+    this.soundDirector.setAppAudioMode(mode)
+  }
+
   handleEffects(effects: GameEffect[]): void {
     this.soundDirector.handleEffects(effects)
     this.hapticsDirector.handleEffects(effects)

+ 41 - 7
miniprogram/pages/map/map.ts

@@ -31,9 +31,10 @@ type MapPageData = MapEngineViewState & {
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-175'
+const INTERNAL_BUILD_VERSION = 'map-build-195'
 const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
 let mapEngine: MapEngine | null = null
+let stageCanvasAttached = false
 function buildSideButtonVisibility(mode: SideButtonMode) {
   return {
     sideButtonMode: mode,
@@ -114,6 +115,8 @@ Page({
     mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
     mockCoordText: '--',
     mockSpeedText: '--',
+    heartRateScanText: '未扫描',
+    heartRateDiscoveredDevices: [],
     panelSpeedValueText: '0',
     panelTelemetryTone: 'blue',
     panelHeartRateZoneNameText: '--',
@@ -150,7 +153,7 @@ Page({
     compassTicks: buildCompassTicks(),
     compassLabels: buildCompassLabels(),
     ...buildSideButtonVisibility('left'),
-  } as MapPageData,
+  } as unknown as MapPageData,
 
   onLoad() {
     const systemInfo = wx.getSystemInfoSync()
@@ -239,15 +242,29 @@ Page({
   },
 
   onReady() {
+    stageCanvasAttached = false
     this.measureStageAndCanvas()
     this.loadMapConfigFromRemote()
   },
 
+  onShow() {
+    if (mapEngine) {
+      mapEngine.handleAppShow()
+    }
+  },
+
+  onHide() {
+    if (mapEngine) {
+      mapEngine.handleAppHide()
+    }
+  },
+
   onUnload() {
     if (mapEngine) {
       mapEngine.destroy()
       mapEngine = null
     }
+    stageCanvasAttached = false
   },
 
   loadMapConfigFromRemote() {
@@ -295,6 +312,10 @@ Page({
 
       currentEngine.setStage(rect)
 
+      if (stageCanvasAttached) {
+        return
+      }
+
       const canvasQuery = wx.createSelectorQuery().in(page)
       canvasQuery.select('#mapCanvas').fields({ node: true, size: true })
       canvasQuery.select('#routeLabelCanvas').fields({ node: true, size: true })
@@ -317,6 +338,7 @@ Page({
             dpr,
             labelCanvasRef && labelCanvasRef.node ? labelCanvasRef.node : undefined,
           )
+          stageCanvasAttached = true
         } catch (error) {
           page.setData({
             statusText: `WebGL 鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
@@ -453,11 +475,23 @@ Page({
     }
   },
 
-  handleDisconnectHeartRate() {
-    if (mapEngine) {
-      mapEngine.handleDisconnectHeartRate()
-    }
-  },
+    handleDisconnectHeartRate() {
+      if (mapEngine) {
+        mapEngine.handleDisconnectHeartRate()
+      }
+    },
+
+    handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
+      if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) {
+        mapEngine.handleConnectHeartRateDevice(event.currentTarget.dataset.deviceId)
+      }
+    },
+
+    handleClearPreferredHeartRateDevice() {
+      if (mapEngine) {
+        mapEngine.handleClearPreferredHeartRateDevice()
+      }
+    },
 
   handleDebugHeartRateBlue() {
     if (mapEngine) {

+ 39 - 18
miniprogram/pages/map/map.wxml

@@ -78,28 +78,28 @@
 
   <cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
     <cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
-    <cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">LOC</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">LOCK</cover-view></cover-view>
-    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">SUN</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">EXIT</cover-view></cover-view>
+    <cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">2</cover-view></cover-view>
+    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">3</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">4</cover-view></cover-view>
   </cover-view>
 
   <cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">N</cover-view></cover-view>
-    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">DIR</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">COMP</cover-view></cover-view>
-    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">GUIDE</cover-view></cover-view>
-    <cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">NET</cover-view></cover-view>
-    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">GO</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">5</cover-view></cover-view>
+    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">6</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">7</cover-view></cover-view>
+    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">8</cover-view></cover-view>
+    <cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">9</cover-view></cover-view>
+    <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">10</cover-view></cover-view>
   </cover-view>
 
   <cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">INFO</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">SET</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">m</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">PIN</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">LIST</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">11</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">12</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">13</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">14</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">16</cover-view></cover-view>
   </cover-view>
 
   <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
@@ -332,6 +332,29 @@
             <text class="info-panel__label">HR Device</text>
             <text class="info-panel__value">{{heartRateDeviceText}}</text>
           </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">HR Scan</text>
+            <text class="info-panel__value">{{heartRateScanText}}</text>
+          </view>
+          <view class="debug-device-list" wx:if="{{heartRateDiscoveredDevices.length}}">
+            <view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
+              <view class="debug-device-card__main">
+                <view class="debug-device-card__title-row">
+                  <text class="debug-device-card__name">{{item.name}}</text>
+                  <text class="debug-device-card__badge" wx:if="{{item.preferred}}">首选</text>
+                </view>
+                <text class="debug-device-card__meta">{{item.rssiText}}</text>
+              </view>
+              <view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
+            </view>
+          </view>
+          <view class="control-row">
+            <view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
+            <view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
+          </view>
+          <view class="control-row">
+            <view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
+          </view>
           <view class="info-panel__row">
             <text class="info-panel__label">Heading Mode</text>
             <text class="info-panel__value">{{orientationModeText}}</text>
@@ -346,14 +369,12 @@
           </view>
           <view class="control-row">
             <view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
-            <view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
           </view>
           <view class="control-row">
             <view class="control-chip {{locationSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealLocationMode">真实定位</view>
             <view class="control-chip {{locationSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockLocationMode">模拟定位</view>
           </view>
           <view class="control-row">
-            <view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
             <view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
           </view>
         </view>

+ 75 - 0
miniprogram/pages/map/map.wxss

@@ -1194,6 +1194,81 @@
   gap: 14rpx;
 }
 
+.debug-device-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  margin-top: 16rpx;
+}
+
+.debug-device-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+  padding: 16rpx 18rpx;
+  border-radius: 18rpx;
+  background: rgba(255, 255, 255, 0.82);
+  box-shadow: inset 0 0 0 2rpx rgba(22, 48, 32, 0.06);
+}
+
+.debug-device-card__main {
+  flex: 1;
+  min-width: 0;
+}
+
+.debug-device-card__title-row {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.debug-device-card__name {
+  flex: 1;
+  min-width: 0;
+  font-size: 24rpx;
+  line-height: 1.3;
+  font-weight: 700;
+  color: #163020;
+  word-break: break-all;
+}
+
+.debug-device-card__badge {
+  flex-shrink: 0;
+  padding: 4rpx 10rpx;
+  border-radius: 999rpx;
+  background: rgba(45, 106, 79, 0.14);
+  color: #2d6a4f;
+  font-size: 18rpx;
+  line-height: 1;
+  font-weight: 700;
+}
+
+.debug-device-card__meta {
+  margin-top: 8rpx;
+  font-size: 20rpx;
+  line-height: 1.2;
+  color: #6a826f;
+}
+
+.debug-device-card__action {
+  flex-shrink: 0;
+  min-width: 96rpx;
+  padding: 16rpx 18rpx;
+  border-radius: 999rpx;
+  background: #eef6ea;
+  color: #2d6a4f;
+  font-size: 22rpx;
+  line-height: 1;
+  text-align: center;
+  font-weight: 700;
+}
+
+.debug-device-card__action--active {
+  background: #2d6a4f;
+  color: #f7fbf2;
+}
+
 .control-row {
   display: flex;
   gap: 14rpx;