2454 lines
71 KiB
Plaintext
2454 lines
71 KiB
Plaintext
<template>
|
||
<scroll-view class="healthble-page" direction="vertical">
|
||
<view class="status-banner" @click="toggleDeviceCard">
|
||
<view class="status-main">
|
||
<l-icon class="status-icon" :name="connectionIcon" :color="connectionIconColor" size="48rpx" />
|
||
<view class="status-texts">
|
||
<text class="status-title">{{ connectionTitle }}</text>
|
||
<text class="status-subtitle">{{ connectionSubtitle }}</text>
|
||
<text v-if="connectionState == 'connected' && batteryText != '--'" class="status-battery"
|
||
:style="{ color: batteryColor }">🔋 {{ batteryText }}</text>
|
||
</view>
|
||
<text v-if="connectionState == 'connected'"
|
||
class="status-expand-icon">{{ deviceCardExpanded ? '▼' : '▶' }}</text>
|
||
</view>
|
||
<view class="status-actions">
|
||
<button class="status-btn status-btn-first" @click="toggleMonitoring"
|
||
:disabled="connectionState == 'connecting'">
|
||
{{ monitoringActionLabel }}
|
||
</button>
|
||
<button class="status-btn status-btn-secondary" @click="refreshHistory" :disabled="isLoadingHistory">
|
||
{{ isLoadingHistory ? '刷新中…' : '刷新记录' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="connectionState != 'connected' || deviceCardExpanded" class="device-card">
|
||
<view class="device-header">
|
||
<view class="device-summary">
|
||
<text class="device-name">{{ deviceName }}</text>
|
||
<text class="device-battery" v-if="batteryText != '--'">🔋 {{ batteryText }}</text>
|
||
</view>
|
||
<button class="device-action" @click="onConnectPressed" :disabled="connectionState == 'connecting'">
|
||
{{ connectionButtonLabel }}
|
||
</button>
|
||
</view>
|
||
<view class="device-meta">
|
||
<text class="meta-item">信号:{{ signalText }}</text>
|
||
<text class="meta-item">最近同步:{{ lastSyncTime }}</text>
|
||
</view>
|
||
<view class="device-meta">
|
||
<text class="meta-item">监测:{{ isMonitoring ? '进行中' : '暂停' }}</text>
|
||
<text class="meta-item">模式:{{ connectionState == 'connected' ? '实时' : '演示' }}</text>
|
||
</view>
|
||
<view class="device-meta">
|
||
<text class="meta-item">硬件版本:{{ hardwareVersionText }}</text>
|
||
<text class="meta-item">软件版本:{{ softwareVersionText }}</text>
|
||
</view>
|
||
<view class="device-meta" v-if="firmwareUpdateAvailable">
|
||
<text class="meta-item firmware-update">🔄 新固件可用:{{ firmwareUpdateInfo?.version }}</text>
|
||
</view>
|
||
<view class="device-meta" v-if="firmwareUpdateInProgress">
|
||
<text class="meta-item firmware-progress">{{ firmwareUpdateProgress?.message }}
|
||
({{ firmwareUpdateProgress?.progress }}%)</text>
|
||
</view>
|
||
<view class="device-meta">
|
||
<text class="meta-item">设备时间:{{ deviceTimeText }}</text>
|
||
</view>
|
||
<button class="scan-btn" @click="scanForDevices" :disabled="isScanning">
|
||
{{ isScanning ? '扫描中…' : '扫描附近设备' }}
|
||
</button>
|
||
<view v-if="connectionState == 'connected'" class="ping-row">
|
||
<button class="ping-btn" @click="sendPingCommand" :disabled="pingInFlight">
|
||
{{ pingInFlight ? 'Ping中…' : '发送 Ping' }}
|
||
</button>
|
||
<text class="ping-status" :class="'ping-' + pingStatusTone">{{ pingStatusMessage }}</text>
|
||
</view>
|
||
<text v-if="connectionState == 'connected' && pingLastSuccessAt != ''" class="ping-meta">最近成功:{{ pingLastSuccessAt }}</text>
|
||
<button v-if="firmwareUpdateAvailable && !firmwareUpdateInProgress" class="firmware-btn primary"
|
||
@click="startFirmwareUpdate">
|
||
升级固件到 {{ firmwareUpdateInfo?.version }}
|
||
</button>
|
||
<button v-if="firmwareUpdateInProgress" class="firmware-btn secondary" disabled>
|
||
升级中... {{ firmwareUpdateProgress?.progress }}%
|
||
</button>
|
||
<scroll-view v-if="discoveredDevices.length > 0" class="device-discovery" direction="horizontal">
|
||
<view class="discovery-chip" v-for="(device, index) in discoveredDevices" :key="index"
|
||
:class="{ active: selectedDeviceId == (device.getString('deviceId') ?? '') }"
|
||
@click="selectDiscoveredDevice(device.getString('deviceId') ?? '')">
|
||
<text class="chip-name">{{ device.getString('name') ?? '未知设备' }}</text>
|
||
<text class="chip-sub">{{ formatRssi(device.getNumber('rssi') ?? 0) }}</text>
|
||
<button class="chip-connect" @click.stop="connectFromDiscovery(device)">连接</button>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<view class="quick-actions">
|
||
<view class="action-item" @click="toggleMonitoring">
|
||
<l-icon class="action-icon" :name="monitoringIcon" size="42rpx" color="#1f2937" />
|
||
<text class="action-label">{{ isMonitoring ? '暂停监测' : '开始监测' }}</text>
|
||
</view>
|
||
<view class="action-item" @click="toggleSimulation">
|
||
<l-icon class="action-icon" :name="simulationIcon" size="42rpx" color="#1f2937" />
|
||
<text class="action-label">{{ useSimulation ? '关闭模拟' : '开启模拟' }}</text>
|
||
</view>
|
||
<view class="action-item" @click="triggerSOS">
|
||
<l-icon class="action-icon" :name="sosIcon" size="42rpx" color="#dc2626" />
|
||
<text class="action-label">紧急报警</text>
|
||
</view>
|
||
<view class="action-item" @click="startRecording">
|
||
<l-icon class="action-icon" :name="recordingIcon" size="42rpx" color="#1f2937" />
|
||
<text class="action-label">录音发送</text>
|
||
</view>
|
||
<view class="action-item" @click="openDiagnostics">
|
||
<l-icon class="action-icon" :name="diagnosticsIcon" size="42rpx" color="#1f2937" />
|
||
<text class="action-label">连接诊断</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="chart-card">
|
||
<view class="chart-header">
|
||
<view class="chart-title-row">
|
||
<text class="chart-title">实时趋势</text>
|
||
</view>
|
||
<view class="chart-controls">
|
||
<view class="time-config">
|
||
<text class="config-label">时间格式:</text>
|
||
<button class="config-btn" :class="{ active: timeFormat == 'ss' }"
|
||
@click="timeFormat = 'ss'">秒</button>
|
||
<button class="config-btn config-btn-last" :class="{ active: timeFormat == 'mm:ss' }"
|
||
@click="timeFormat = 'mm:ss'">分:秒</button>
|
||
</view>
|
||
<view class="interval-config">
|
||
<text class="config-label">间隔:</text>
|
||
<button class="config-btn" :class="{ active: timeInterval == 1 }"
|
||
@click="timeInterval = 1">1秒</button>
|
||
<button class="config-btn" :class="{ active: timeInterval == 3 }"
|
||
@click="timeInterval = 3">3秒</button>
|
||
<button class="config-btn config-btn-last" :class="{ active: timeInterval == 5 }"
|
||
@click="timeInterval = 5">5秒</button>
|
||
</view>
|
||
</view>
|
||
<view class="chart-tabs">
|
||
<button v-for="(type, idx) in chartTypes" :key="idx" class="chart-tab"
|
||
:class="{ active: activeMetric == type }" @click="switchMetric(type)">
|
||
{{ getMetricLabel(type) }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
<ak-charts class="chart-view" canvas-id="health-chart" :option="chartOption" />
|
||
</view>
|
||
|
||
<view class="metrics-card">
|
||
<view class="metric" v-for="(metric, index) in realtimeMetrics" :key="index">
|
||
<view class="metric-header">
|
||
<text class="metric-icon">{{ metric.icon }}</text>
|
||
<text class="metric-label">{{ metric.label }}</text>
|
||
</view>
|
||
<view class="metric-value-row">
|
||
<text class="metric-value">{{ metric.value }}</text>
|
||
<text class="metric-unit">{{ metric.unit }}</text>
|
||
</view>
|
||
<view class="metric-extreme">
|
||
<text class="metric-range">最高 {{ metric.high ?? '--' }}</text>
|
||
<text class="metric-range">最低 {{ metric.low ?? '--' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="history-card">
|
||
<view class="history-header">
|
||
<text class="history-title">最新记录</text>
|
||
<button class="history-more" @click="refreshHistory" :disabled="isLoadingHistory">
|
||
{{ isLoadingHistory ? '加载中…' : '刷新' }}
|
||
</button>
|
||
</view>
|
||
<view v-if="historyMeasurements.length > 0" class="history-list">
|
||
<view class="history-item" v-for="(item, index) in historyMeasurements" :key="index">
|
||
<view class="history-main">
|
||
<text class="history-type">{{ getMetricLabel(item.measurement_type ?? '') }}</text>
|
||
<text class="history-value">{{ formatHistoryValue(item) }}</text>
|
||
</view>
|
||
<text class="history-time">{{ formatHistoryTime(item.measured_at ?? '') }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-else class="history-empty">
|
||
<text class="empty-text">暂无记录,点击上方按钮刷新。</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="currentAnomaly != ''" class="alert-banner">
|
||
<text class="alert-icon">⚠️</text>
|
||
<text class="alert-text">{{ currentAnomaly }}</text>
|
||
</view>
|
||
|
||
<view v-if="isRecording" class="recording-panel">
|
||
<text class="recording-title">录音中 {{ recordingClock }}</text>
|
||
<view class="recording-actions">
|
||
<button class="recording-btn secondary" @click="stopRecording(false)">取消</button>
|
||
<button class="recording-btn primary" @click="stopRecording(true)">完成</button>
|
||
</view>
|
||
</view>
|
||
|
||
</scroll-view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
// @ts-nocheck
|
||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import akCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
|
||
import LIcon from '@/uni_modules/lime-icon/components/l-icon/l-icon.uvue'
|
||
import { state, loadDevicesWithDefault, setCurrentDevice, bindNewDevice } from '@/utils/store.uts'
|
||
import { SenseDataService, type SensorDataParams } from './senseDataService.uts'
|
||
import type { DeviceInfo, SensorMeasurement, NotifyLogItem, RealtimeMetric, ChartBuffer, HealthSample } from './types.uts'
|
||
import { ProtocolHandler, type PingResult } from '@/uni_modules/ak-sbsrv/utssdk/protocol_handler.uts'
|
||
import type { BleDevice, BleEventPayload, BleService, BleCharacteristic, BluetoothService, BleConnectOptionsExt, HealthData } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||
import { processHeartRateData, processSpo2Data, processStepsData, createMultiMetricCharts, getLatestValue, hasEnoughDataForChart, type ChartOption } from '@/uts/utils/chartDataUtils.uts'
|
||
import { dfuTool, DFUTool, type FirmwareInfo, type DFUProgress } from '@/uts/utils/dfu.uts'
|
||
|
||
|
||
|
||
// #ifdef APP-ANDROID
|
||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/app-android/index.uts'
|
||
// #endif
|
||
// #ifndef APP-ANDROID
|
||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
|
||
// #endif
|
||
|
||
import { BLE_PREFIX } from '@/utils/bleConfig.uts'
|
||
// In-memory log of incoming notify packets for debugging/export
|
||
defineOptions({
|
||
components: {
|
||
'l-icon': LIcon
|
||
}
|
||
})
|
||
|
||
const ICON_FONT_FAMILY = 'keyicon'
|
||
const ICON_FONT_SOURCE = 'url("https://at.alicdn.com/t/c/font_4741157_ul7wcp52yys.ttf")'
|
||
let iconFontLoaded = false
|
||
let iconFontLoading = false
|
||
|
||
function ensureIconFontLoaded() : void {
|
||
if (iconFontLoaded || iconFontLoading) {
|
||
return
|
||
}
|
||
if (typeof uni.loadFontFace != 'function') {
|
||
return
|
||
}
|
||
iconFontLoading = true
|
||
try {
|
||
uni.loadFontFace({
|
||
family: ICON_FONT_FAMILY,
|
||
source: ICON_FONT_SOURCE,
|
||
success: (res: any) => {
|
||
iconFontLoaded = true
|
||
},
|
||
fail: (err: any) => {
|
||
console.warn('[healthble] loadFontFace failed', err)
|
||
},
|
||
complete: (res: any) => {
|
||
iconFontLoading = false
|
||
}
|
||
})
|
||
} catch (err) {
|
||
iconFontLoading = false
|
||
console.warn('[healthble] loadFontFace threw', err)
|
||
}
|
||
}
|
||
|
||
function getBluetoothService() : BluetoothService {
|
||
return bluetoothService
|
||
}
|
||
|
||
const selectedDeviceId = ref<string>('')
|
||
|
||
onLoad((options : OnLoadOptions) => {
|
||
ensureIconFontLoaded()
|
||
})
|
||
|
||
// UI event handler functions - must be defined before template usage
|
||
function selectDiscoveredDevice(deviceId : string) {
|
||
if (deviceId == '') return
|
||
selectedDeviceId.value = deviceId
|
||
}
|
||
|
||
|
||
const bluetoothSvc = getBluetoothService()
|
||
const protocolHandler = new ProtocolHandler(bluetoothSvc)
|
||
const REQUIRED_SERVICE_UUIDS = [
|
||
'0000180a-0000-1000-8000-00805f9b34fb', // Device Information
|
||
'00001805-0000-1000-8000-00805f9b34fb', // Current Time
|
||
'0000180f-0000-1000-8000-00805f9b34fb' // Battery Service
|
||
]
|
||
|
||
const connectionState = ref<string>('disconnected')
|
||
const isMonitoring = ref<boolean>(false)
|
||
const isScanning = ref<boolean>(false)
|
||
const isLoadingHistory = ref<boolean>(false)
|
||
const batteryLevel = ref<number | null>(null)
|
||
const isCharging = ref<boolean>(false)
|
||
const deviceCardExpanded = ref<boolean>(false)
|
||
const useSimulation = ref<boolean>(false)
|
||
const signalRssi = ref<number | null>(null)
|
||
const lastSyncTime = ref<string>('--')
|
||
const activeMetric = ref<string>('heart_rate')
|
||
const currentAnomaly = ref<string>('')
|
||
const isRecording = ref<boolean>(false)
|
||
const recordingSeconds = ref<number>(0)
|
||
const discoveredDevices = ref<Array<UTSJSONObject>>([])
|
||
// 可配置的设备名前缀;置空则不进行前缀过滤
|
||
const blePrefix = ref<string>(BLE_PREFIX ?? 'CF')
|
||
const hardwareVersion = ref<string>('--')
|
||
const softwareVersion = ref<string>('--')
|
||
const deviceTimeDisplay = ref<string>('--')
|
||
const deviceInfoLoading = ref<boolean>(false)
|
||
const healthSubscribed = ref<boolean>(false)
|
||
const pingInFlight = ref<boolean>(false)
|
||
const pingStatusMessage = ref<string>('未测试')
|
||
const pingStatusTone = ref<string>('idle')
|
||
const pingLastSuccessAt = ref<string>('')
|
||
// 固件升级相关状态
|
||
const firmwareUpdateAvailable = ref<boolean>(false)
|
||
const firmwareUpdateInfo = ref<FirmwareInfo | null>(null)
|
||
const firmwareUpdateProgress = ref<DFUProgress | null>(null)
|
||
const firmwareUpdateInProgress = ref<boolean>(false)
|
||
const monitoringIcon = computed(() : string => isMonitoring.value ? 'pause-circle' : 'play-circle')
|
||
const simulationIcon = computed(() : string => useSimulation.value ? 'chart-analytics' : 'chart-line-data')
|
||
const sosIcon = 'alarm'
|
||
const recordingIcon = 'microphone'
|
||
const diagnosticsIcon = 'bug-report'
|
||
const baseMetrics : Array<RealtimeMetric> = [
|
||
{ type: 'heart_rate', label: '心率', icon: '❤️', value: '--', unit: 'bpm', high: '--', low: '--' },
|
||
{ type: 'spo2', label: '血氧', icon: '🩸', value: '--', unit: '%', high: '--', low: '--' },
|
||
{ type: 'steps', label: '步数', icon: '👟', value: '--', unit: '步', high: '--', low: '--' },
|
||
{ type: 'speed', label: '速度', icon: '🏃', value: '--', unit: 'km/h', high: '--', low: '--' }
|
||
]
|
||
const metricsSeed = [] as Array<RealtimeMetric>
|
||
const notifyLog = ref<NotifyLogItem[]>([])
|
||
function getChartColor(type : string) : string {
|
||
if (type == 'heart_rate') return '#FF6B6B'
|
||
if (type == 'spo2') return '#2196F3'
|
||
if (type == 'steps') return '#4CAF50'
|
||
if (type == 'speed') return '#FFA726'
|
||
return '#5C6BC0'
|
||
}
|
||
function createChartOption(metricType : string) : UTSJSONObject {
|
||
// 使用新的数据处理工具
|
||
const healthDataArray = notifyLog.value.map((log : NotifyLogItem) => log.pkt as HealthData).filter((pkt : HealthData | null) => pkt != null) as Array<HealthData>
|
||
const interval = 1 // 默认使用1秒间隔
|
||
if (healthDataArray.length == 0) {
|
||
return new UTSJSONObject()
|
||
}
|
||
|
||
let chartOptionData : ChartOption | null = null
|
||
if (metricType === 'heart_rate') {
|
||
chartOptionData = processHeartRateData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
|
||
} else if (metricType === 'spo2') {
|
||
chartOptionData = processSpo2Data(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
|
||
} else if (metricType === 'steps') {
|
||
chartOptionData = processStepsData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
|
||
} else if (metricType === 'overview') {
|
||
// Overview: show single heart-rate series (line with points) sampled at configured interval
|
||
chartOptionData = processHeartRateData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: 1 }) as ChartOption
|
||
} else {
|
||
// 默认使用心率数据
|
||
chartOptionData = processHeartRateData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
|
||
}
|
||
|
||
// 转换为UTSJSONObject格式
|
||
const option = new UTSJSONObject()
|
||
if (chartOptionData != null) {
|
||
option.set('type', chartOptionData.type)
|
||
option.set('data', chartOptionData.data)
|
||
const labels = chartOptionData.labels ?? []
|
||
option.set('labels', labels as Array<string>)
|
||
const color = chartOptionData.color ?? getChartColor(metricType)
|
||
option.set('color', color as string)
|
||
}
|
||
return option
|
||
}
|
||
|
||
|
||
|
||
const realtimeMetrics = ref<Array<RealtimeMetric>>(metricsSeed)
|
||
const chartBuffers = ref<Array<ChartBuffer>>([
|
||
{ type: 'heart_rate', data: [], labels: [] },
|
||
{ type: 'spo2', data: [], labels: [] },
|
||
{ type: 'steps', data: [], labels: [] },
|
||
{ type: 'speed', data: [], labels: [] }
|
||
])
|
||
const chartOption = ref<UTSJSONObject>(createChartOption('heart_rate'))
|
||
const historyMeasurements = ref<Array<SensorMeasurement>>([])
|
||
const chartTypes = ['overview', 'heart_rate', 'spo2', 'steps', 'speed']
|
||
|
||
// 时间轴配置
|
||
const timeFormat = ref<'ss' | 'mm:ss'>('ss')
|
||
const timeInterval = ref<1 | 3 | 5>(1)
|
||
|
||
// 用于存储间隔缓冲区和时间
|
||
const intervalBuffers = new Map<string, Array<number>>()
|
||
const lastIntervalTimes = new Map<string, number>()
|
||
|
||
let monitoringTimer : number | null = null
|
||
let recordingTimer : number | null = null
|
||
let chartLastUpdateTime = 0 // 图表最后更新时间戳
|
||
let heartRateHigh = 0
|
||
let heartRateLow = 999
|
||
let spo2High = 0
|
||
let spo2Low = 100
|
||
let speedHigh = 0
|
||
const dailyStepTotal = ref<number>(0)
|
||
|
||
function appendNotifyLog(pkt : HealthData, hex : string | null, b64 : string | null) {
|
||
try {
|
||
const item : NotifyLogItem = {
|
||
ts: Date.now(),
|
||
pkt: pkt as HealthData,
|
||
hex: hex,
|
||
b64: b64
|
||
}
|
||
notifyLog.value.push(item)
|
||
// keep bounded to avoid unbounded memory growth
|
||
if (notifyLog.value.length > 5000) {
|
||
notifyLog.value.shift()
|
||
}
|
||
} catch (e) { console.warn('[healthble] appendNotifyLog failed', e) }
|
||
}
|
||
|
||
function switchMetric(type : string) {
|
||
activeMetric.value = type
|
||
}
|
||
|
||
function stopRecording(save : boolean) {
|
||
if (!isRecording.value) return
|
||
isRecording.value = false
|
||
if (recordingTimer != null) {
|
||
clearInterval(recordingTimer as number)
|
||
recordingTimer = null
|
||
}
|
||
if (save) {
|
||
uni.showToast({ title: '录音已保存', icon: 'success' })
|
||
} else {
|
||
uni.showToast({ title: '录音已取消', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
// #ifdef WEB
|
||
function _fallbackBlob(dump : string) {
|
||
try {
|
||
if (typeof document != 'undefined' && typeof window != 'undefined') {
|
||
const blob = new Blob([dump], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
setTimeout(() => URL.revokeObjectURL(url), 1500)
|
||
return
|
||
}
|
||
console.log('[healthble] exportNotifyLog fallback output:', dump)
|
||
} catch (e) { console.warn('[healthble] exportNotifyLog fallback failed', e) }
|
||
}
|
||
|
||
|
||
// #endif
|
||
function exportNotifyLog() {
|
||
try {
|
||
const dump = JSON.stringify(notifyLog.value, null, 2)
|
||
// Try uni-app FileSystemManager when available (mini-programs / native runtimes)
|
||
try {
|
||
if (typeof uni.getFileSystemManager == 'function') {
|
||
const fs = uni.getFileSystemManager()
|
||
const name = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||
let filePath = name
|
||
try {
|
||
// prefer USER_DATA_PATH when present (mini-programs / app)
|
||
if (uni.env != null && uni.env.USER_DATA_PATH != null) filePath = `${uni.env.USER_DATA_PATH}/${name}`
|
||
} catch (e) { /* ignore env issues */ }
|
||
fs.writeFile({
|
||
filePath,
|
||
data: dump,
|
||
encoding: 'utf8',
|
||
success: () => {
|
||
console.log('[healthble] exportNotifyLog wrote file to', filePath)
|
||
try { uni.showToast({ title: '日志已保存', icon: 'none' }) } catch (e) { }
|
||
},
|
||
fail: (err) => {
|
||
console.warn('[healthble] exportNotifyLog writeFile failed', err)
|
||
// fallback to browser download
|
||
// #ifdef WEB
|
||
_fallbackBlob(dump)
|
||
// #endif
|
||
}
|
||
})
|
||
return
|
||
}
|
||
} catch (e) {
|
||
console.warn('[healthble] exportNotifyLog uni write attempt failed', e)
|
||
}
|
||
|
||
// Browser / H5 fallback: trigger a download
|
||
// #ifdef WEB
|
||
_fallbackBlob(dump)
|
||
// #endif
|
||
} catch (e) { console.warn('[healthble] exportNotifyLog failed', e) }
|
||
}
|
||
|
||
function clearNotifyLog() {
|
||
notifyLog.value = []
|
||
}
|
||
|
||
function parseHealthSampleFromRaw(rawValue : Uint8Array | null, fallbackTs : number) : HealthSample | null {
|
||
if (rawValue == null) return null
|
||
|
||
// 简化处理:假设raw数据是简单的二进制格式
|
||
// 这里可以根据实际的协议格式进行解析
|
||
// 目前返回null,因为具体的解析逻辑需要根据设备协议确定
|
||
return null
|
||
}
|
||
function getChartBuffer(metricType : string) : ChartBuffer {
|
||
const buffers = chartBuffers.value
|
||
for (let i : Int = 0; i < buffers.length; i++) {
|
||
const buffer = buffers[i]
|
||
if (buffer.type == metricType) return buffer
|
||
}
|
||
const buffer : ChartBuffer = { type: metricType, data: [], labels: [] }
|
||
buffers.push(buffer)
|
||
return buffer
|
||
}
|
||
|
||
function formatTimeLabel(date : Date) : string {
|
||
if (timeFormat.value == 'ss') {
|
||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||
return seconds
|
||
} else { // 'mm:ss'
|
||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||
return `${minutes}:${seconds}`
|
||
}
|
||
}
|
||
|
||
function appendChartPoint(type : string, value : number, timestamp : Date) {
|
||
try {
|
||
// 为每个指标类型维护临时缓冲区来收集当前时间间隔内的数据
|
||
const bufferKey = `${type}_intervalBuffer`
|
||
const lastIntervalKey = `${type}_lastIntervalTime`
|
||
|
||
// 确保间隔缓冲区存在
|
||
if (!intervalBuffers.has(bufferKey)) {
|
||
intervalBuffers.set(bufferKey, [])
|
||
}
|
||
if (!lastIntervalTimes.has(lastIntervalKey)) {
|
||
lastIntervalTimes.set(lastIntervalKey, timestamp.getTime())
|
||
}
|
||
|
||
// 安全读取
|
||
const intervalBuffer = intervalBuffers.get(bufferKey) as Array<number>
|
||
const lastIntervalTime = lastIntervalTimes.get(lastIntervalKey) as number
|
||
const currentTime = timestamp.getTime()
|
||
const intervalMs = 1000 // 默认1秒间隔
|
||
|
||
// 强制值为数字(防止意外类型)
|
||
let numericValue = 0
|
||
if (value != null) {
|
||
numericValue = value as number
|
||
}
|
||
|
||
// 添加当前值到缓冲区
|
||
intervalBuffer.push(numericValue)
|
||
|
||
// 检查是否到达时间间隔结束
|
||
if (currentTime - lastIntervalTime >= intervalMs) {
|
||
// 找出当前间隔内的最高值(防御性编程:处理非数组或空数组)
|
||
let maxValue = numericValue
|
||
if (Array.isArray(intervalBuffer) && intervalBuffer.length > 0) {
|
||
try {
|
||
const values = intervalBuffer.map(v => v as number)
|
||
maxValue = Math.max(...values)
|
||
} catch (e) {
|
||
// 如果 Math.max 仍出错,保守回退为当前值
|
||
console.warn('[healthble] Math.max failed on intervalBuffer, fallback to numericValue', e)
|
||
maxValue = numericValue
|
||
}
|
||
}
|
||
|
||
// 添加最高值到图表
|
||
const buffer = getChartBuffer(type)
|
||
buffer.data.push(maxValue)
|
||
buffer.labels.push(formatTimeLabel(timestamp))
|
||
|
||
// 使用与createChartOption相同的maxPoints配置
|
||
const maxPoints = 100
|
||
const scrollThreshold = Math.floor(maxPoints * 0.75) // 75%时开始滚动
|
||
|
||
// 当达到滚动阈值时,开始移除最旧的数据以实现滚动效果
|
||
if (buffer.data.length > scrollThreshold) {
|
||
buffer.data.shift()
|
||
buffer.labels.shift()
|
||
}
|
||
|
||
// 确保不超过最大点数
|
||
if (buffer.data.length > maxPoints) {
|
||
buffer.data.shift()
|
||
buffer.labels.shift()
|
||
}
|
||
|
||
// 控制图表更新频率:每0.75秒最多更新一次
|
||
const now = Date.now()
|
||
const updateInterval = 750 // 0.75秒
|
||
|
||
if (activeMetric.value == type && (now - chartLastUpdateTime) >= updateInterval) {
|
||
chartOption.value = createChartOption(type)
|
||
chartLastUpdateTime = now
|
||
}
|
||
|
||
// 重置缓冲区和时间戳
|
||
intervalBuffers.set(bufferKey, [])
|
||
lastIntervalTimes.set(lastIntervalKey, currentTime)
|
||
}
|
||
} catch (err) {
|
||
console.error('[healthble] appendChartPoint error', err, { type, value, timestamp })
|
||
}
|
||
}
|
||
function pad2(value : number) : string {
|
||
return value.toString().padStart(2, '0')
|
||
}
|
||
|
||
function updateMetricValue(type : string, value : number, high : number | null, low : number | null) {
|
||
const metrics = realtimeMetrics.value
|
||
for (let i : Int = 0; i < metrics.length; i++) {
|
||
const metric = metrics[i]
|
||
if (metric.type == type) {
|
||
let formatted = ''
|
||
if (type == 'speed') {
|
||
formatted = value.toFixed(1)
|
||
} else if (type == 'spo2') {
|
||
formatted = value.toFixed(0)
|
||
} else {
|
||
formatted = value.toFixed(0)
|
||
}
|
||
metric.value = formatted
|
||
if (high != null) {
|
||
metric.high = (type == 'speed' ? high.toFixed(1) : high.toFixed(0))
|
||
}
|
||
if (low != null) {
|
||
metric.low = (type == 'speed' ? low.toFixed(1) : low.toFixed(0))
|
||
}
|
||
return
|
||
}
|
||
}
|
||
}
|
||
function formatFullTime(date : Date) : string {
|
||
const year = date.getFullYear()
|
||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||
const day = date.getDate().toString().padStart(2, '0')
|
||
const hour = date.getHours().toString().padStart(2, '0')
|
||
const minute = date.getMinutes().toString().padStart(2, '0')
|
||
const second = date.getSeconds().toString().padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||
}
|
||
|
||
function applySample(sample : HealthSample, fromDevice : boolean) {
|
||
if (heartRateHigh < sample.heartRate) heartRateHigh = sample.heartRate
|
||
if (heartRateLow > sample.heartRate) heartRateLow = sample.heartRate
|
||
if (heartRateLow == 999) heartRateLow = sample.heartRate
|
||
if (spo2High < sample.spo2) spo2High = sample.spo2
|
||
if (spo2Low > sample.spo2) spo2Low = sample.spo2
|
||
if (spo2Low == 100) spo2Low = sample.spo2
|
||
if (speedHigh < sample.speed) speedHigh = sample.speed
|
||
const stepIncrement = Math.max(0, Math.round(sample.steps))
|
||
dailyStepTotal.value = dailyStepTotal.value + stepIncrement
|
||
|
||
updateMetricValue('heart_rate', sample.heartRate, heartRateHigh, heartRateLow)
|
||
updateMetricValue('spo2', sample.spo2, spo2High, spo2Low)
|
||
updateMetricValue('speed', sample.speed, speedHigh, 0)
|
||
updateMetricValue('steps', dailyStepTotal.value, dailyStepTotal.value, Math.max(0, dailyStepTotal.value - stepIncrement))
|
||
|
||
appendChartPoint('heart_rate', sample.heartRate, sample.timestamp)
|
||
appendChartPoint('spo2', sample.spo2, sample.timestamp)
|
||
appendChartPoint('speed', sample.speed, sample.timestamp)
|
||
appendChartPoint('steps', dailyStepTotal.value, sample.timestamp)
|
||
|
||
if (sample.heartRate > 115) {
|
||
currentAnomaly.value = '心率偏高,请注意休息或降低运动强度'
|
||
} else if (sample.spo2 < 94) {
|
||
currentAnomaly.value = '血氧低于安全阈值,建议暂停运动并深呼吸'
|
||
} else {
|
||
currentAnomaly.value = ''
|
||
}
|
||
|
||
if (fromDevice) {
|
||
lastSyncTime.value = formatFullTime(sample.timestamp)
|
||
if (signalRssi.value == null) {
|
||
signalRssi.value = -55 + Math.floor(Math.random() * 6)
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleHealthData(packet : HealthData) : void {
|
||
try { console.log('[healthble] notify packet', packet) } catch (e) { }
|
||
appendNotifyLog(packet, null, null)
|
||
|
||
const baseTs = packet.timestamp ?? Date.now()
|
||
const packetType = packet.type ?? ''
|
||
if (packetType == 'heart') {
|
||
const heart = packet.heartRate ?? packet.pulse
|
||
if (heart != null && heart > 0) {
|
||
applySample({ heartRate: heart, spo2: 0, steps: 0, speed: 0, timestamp: new Date(baseTs) }, true)
|
||
return
|
||
}
|
||
} else if (packetType == 'spo2') {
|
||
const spo2 = packet.spo2
|
||
if (spo2 != null && spo2 > 0) {
|
||
applySample({ heartRate: 0, spo2: spo2, steps: 0, speed: 0, timestamp: new Date(baseTs) }, true)
|
||
return
|
||
}
|
||
} else if (packetType == 'steps') {
|
||
const steps = packet.steps
|
||
if (steps != null && steps >= 0) {
|
||
applySample({ heartRate: 0, spo2: 0, steps: steps, speed: 0, timestamp: new Date(baseTs) }, true)
|
||
return
|
||
}
|
||
} else if (packetType == 'raw') {
|
||
const sample = parseHealthSampleFromRaw(packet.raw, baseTs)
|
||
if (sample != null) {
|
||
applySample(sample, true)
|
||
return
|
||
}
|
||
}
|
||
|
||
const heartFallback = packet.heartRate ?? packet.pulse
|
||
const spo2Fallback = packet.spo2
|
||
const stepsFallback = packet.steps
|
||
if ((heartFallback != null && heartFallback > 0) || (spo2Fallback != null && spo2Fallback > 0) || (stepsFallback != null && stepsFallback >= 0)) {
|
||
applySample({
|
||
heartRate: heartFallback ?? 0,
|
||
spo2: spo2Fallback ?? 0,
|
||
steps: stepsFallback ?? 0,
|
||
speed: 0,
|
||
timestamp: new Date(baseTs)
|
||
}, true)
|
||
}
|
||
}
|
||
|
||
|
||
for (let i : Int = 0; i < baseMetrics.length; i++) {
|
||
const item = baseMetrics[i]
|
||
const metric : RealtimeMetric = {
|
||
type: item.type,
|
||
label: item.label,
|
||
icon: item.icon,
|
||
value: item.value,
|
||
unit: item.unit,
|
||
high: item.high,
|
||
low: item.low
|
||
}
|
||
metricsSeed.push(metric)
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const connectedDevice = computed<DeviceInfo | null>(() => state.deviceState.currentDevice)
|
||
const deviceName = computed<string>(() => {
|
||
const device = connectedDevice.value
|
||
if (device != null) {
|
||
const name = device.device_name
|
||
if (name != null && name != '') return name
|
||
}
|
||
if (state.deviceState.devices.length > 0) {
|
||
const fallback = state.deviceState.devices[0]
|
||
const fallbackName = fallback.device_name
|
||
if (fallbackName != null && fallbackName != '') return fallbackName
|
||
}
|
||
return '健康手环'
|
||
})
|
||
const connectionIcon = computed<string>(() => {
|
||
const stateValue = connectionState.value
|
||
if (stateValue == 'connected') return 'bluetooth'
|
||
if (stateValue == 'connecting') return 'loading'
|
||
if (stateValue == 'scanning') return 'search'
|
||
return 'close-circle'
|
||
})
|
||
const connectionIconColor = computed<string>(() => {
|
||
const stateValue = connectionState.value
|
||
if (stateValue == 'connected') return '#22c55e'
|
||
if (stateValue == 'connecting') return '#facc15'
|
||
if (stateValue == 'scanning') return '#2563eb'
|
||
return '#ef4444'
|
||
})
|
||
const connectionTitle = computed<string>(() => {
|
||
switch (connectionState.value) {
|
||
case 'connected':
|
||
return deviceName.value
|
||
case 'connecting':
|
||
return '正在连接设备'
|
||
case 'scanning':
|
||
return '正在扫描设备'
|
||
default:
|
||
return '设备未连接'
|
||
}
|
||
})
|
||
const monitoringActionLabel = computed<string>(() => isMonitoring.value ? '暂停监测' : '开始监测')
|
||
const batteryText = computed<string>(() => {
|
||
if (batteryLevel.value == null) return '--'
|
||
const percent = Math.max(0, Math.min(100, Math.round(batteryLevel.value as number)))
|
||
return isCharging.value ? `${percent}% (充电中)` : `${percent}%`
|
||
})
|
||
const batteryColor = computed<string>(() => {
|
||
if (batteryLevel.value == null) return '#666'
|
||
const percent = Math.max(0, Math.min(100, Math.round(batteryLevel.value as number)))
|
||
if (percent < 20) return '#ef4444' // 红色
|
||
if (percent < 50) return '#f59e0b' // 橙色
|
||
return '#10b981' // 绿色
|
||
})
|
||
function describeSignal(rssi : number) : string {
|
||
if (rssi >= -60) return '强'
|
||
if (rssi >= -75) return '中'
|
||
return '弱'
|
||
}
|
||
const signalText = computed<string>(() => {
|
||
const rssi = signalRssi.value
|
||
if (rssi == null) return '未检测'
|
||
const strength = describeSignal(rssi as number)
|
||
return `${strength} (${rssi} dBm)`
|
||
})
|
||
const connectionButtonLabel = computed<string>(() => {
|
||
if (connectionState.value == 'connected') return '断开设备'
|
||
if (connectionState.value == 'connecting') return '连接中…'
|
||
return '连接设备'
|
||
})
|
||
|
||
function formatDuration(seconds : number) : string {
|
||
const minutes = Math.floor(seconds / 60)
|
||
const secs = seconds % 60
|
||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
const recordingClock = computed<string>(() => formatDuration(recordingSeconds.value))
|
||
const hardwareVersionText = computed<string>(() => hardwareVersion.value != '' ? hardwareVersion.value : '--')
|
||
const softwareVersionText = computed<string>(() => softwareVersion.value != '' ? softwareVersion.value : '--')
|
||
const deviceTimeText = computed<string>(() => {
|
||
if (deviceInfoLoading.value) return '获取中…'
|
||
if (deviceTimeDisplay.value == '') return '--'
|
||
return deviceTimeDisplay.value
|
||
})
|
||
|
||
|
||
|
||
function getMetricLabel(type : string) : string {
|
||
if (type == 'overview') return '总览'
|
||
if (type == 'heart_rate') return '心率'
|
||
if (type == 'spo2') return '血氧'
|
||
if (type == 'steps') return '步数'
|
||
if (type == 'speed') return '速度'
|
||
return type
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function generateSample() : HealthSample {
|
||
const now = new Date()
|
||
const heart = 72 + Math.sin(now.getTime() / 8000) * 8 + Math.random() * 3
|
||
const spo2 = 97 + Math.sin(now.getTime() / 12000) * 1 + Math.random() * 0.8
|
||
const steps = Math.random() * 10
|
||
const speed = 5.5 + Math.sin(now.getTime() / 9000) * 1.4 + Math.random() * 0.6
|
||
return {
|
||
heartRate: heart,
|
||
spo2: spo2,
|
||
steps: steps,
|
||
speed: speed,
|
||
timestamp: now
|
||
}
|
||
}
|
||
|
||
function startSimulation() {
|
||
if (monitoringTimer != null) return
|
||
monitoringTimer = setInterval(() => {
|
||
const sample = generateSample()
|
||
applySample(sample, false)
|
||
}, 3000) as number
|
||
}
|
||
|
||
function stopSimulation() {
|
||
if (monitoringTimer != null) {
|
||
clearInterval(monitoringTimer as number)
|
||
monitoringTimer = null
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function startMonitoring() {
|
||
if (!isMonitoring.value) {
|
||
isMonitoring.value = true
|
||
}
|
||
if (useSimulation.value) {
|
||
startSimulation()
|
||
applySample(generateSample(), false)
|
||
}
|
||
}
|
||
|
||
function stopMonitoring() {
|
||
isMonitoring.value = false
|
||
stopSimulation()
|
||
// Platform-specific handling: use uni file API on non-H5, and Blob download on H5
|
||
const dump = JSON.stringify(notifyLog.value, null, 2)
|
||
// #ifndef H5
|
||
try {
|
||
const fs = uni.getFileSystemManager()
|
||
const name = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||
let filePath = name
|
||
try { if (uni.env != null && uni.env.USER_DATA_PATH != null) filePath = `${uni.env.USER_DATA_PATH}/${name}` } catch (e) { }
|
||
fs.writeFile({
|
||
|
||
filePath, data: dump, success: () => {
|
||
console.log('[healthble] exportNotifyLog wrote file to', filePath)
|
||
try { uni.showToast({ title: '日志已保存', icon: 'none' }) } catch (e) { }
|
||
}, fail: (err) => {
|
||
console.warn('[healthble] exportNotifyLog writeFile failed', err)
|
||
// If write fails, fall through to H5 fallback if compiled for H5, otherwise log
|
||
console.log('[healthble] exportNotifyLog fallback to console')
|
||
}
|
||
|
||
})
|
||
return
|
||
} catch (e) {
|
||
console.warn('[healthble] exportNotifyLog write attempt failed', e)
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
// Browser / H5: trigger a download via Blob
|
||
try {
|
||
const blob = new Blob([dump], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
setTimeout(() => URL.revokeObjectURL(url), 1500)
|
||
return
|
||
} catch (e) { console.warn('[healthble] exportNotifyLog H5 fallback failed', e); }
|
||
// #endif
|
||
|
||
}
|
||
function toggleMonitoring() {
|
||
if (isMonitoring.value) {
|
||
stopMonitoring()
|
||
} else {
|
||
startMonitoring()
|
||
}
|
||
}
|
||
function toggleSimulation() {
|
||
useSimulation.value = !useSimulation.value
|
||
if (useSimulation.value && isMonitoring.value) {
|
||
startSimulation()
|
||
applySample(generateSample(), false)
|
||
} else if (!useSimulation.value) {
|
||
stopSimulation()
|
||
}
|
||
}
|
||
function toggleDeviceCard() {
|
||
if (connectionState.value == 'connected') {
|
||
deviceCardExpanded.value = !deviceCardExpanded.value
|
||
}
|
||
}
|
||
function addDiscoveredDevice(device : BleDevice) {
|
||
if (device == null) return
|
||
// 若配置了前缀,则只显示以该前缀开头的设备(不区分大小写)
|
||
const prefix = (blePrefix.value ?? '').toString().trim()
|
||
const devName = (device.name ?? '').toString()
|
||
if (devName == '') return
|
||
if (prefix != '' && !devName.toUpperCase().startsWith(prefix.toUpperCase())) return
|
||
const deviceId = device.deviceId ?? ''
|
||
const currentList = discoveredDevices.value
|
||
const list = [] as Array<UTSJSONObject>
|
||
for (let i : Int = 0; i < currentList.length; i++) {
|
||
const existing = currentList[i]
|
||
const existingId = existing.getString('deviceId') ?? ''
|
||
if (existingId != deviceId) {
|
||
list.push(existing)
|
||
}
|
||
}
|
||
const obj = new UTSJSONObject()
|
||
obj.set('deviceId', deviceId)
|
||
obj.set('name', device.name ?? '未知设备')
|
||
obj.set('rssi', device.rssi ?? 0)
|
||
list.push(obj)
|
||
discoveredDevices.value = list
|
||
|
||
}
|
||
async function simulateDiscovery() {
|
||
await new Promise<void>((resolve) => {
|
||
setTimeout(() => {
|
||
const mock = new UTSJSONObject()
|
||
mock.set('deviceId', 'mock-device')
|
||
mock.set('name', '演示健康手环')
|
||
mock.set('rssi', -58)
|
||
discoveredDevices.value = [mock]
|
||
resolve()
|
||
}, 1200)
|
||
})
|
||
}
|
||
async function scanForDevices() {
|
||
if (isScanning.value) return
|
||
isScanning.value = true
|
||
discoveredDevices.value = []
|
||
const previousState = connectionState.value
|
||
connectionState.value = 'scanning'
|
||
try {
|
||
if (bluetoothSvc != null) {
|
||
// #ifdef APP-ANDROID
|
||
await bluetoothSvc.scanDevices({
|
||
optionalServices: REQUIRED_SERVICE_UUIDS,
|
||
onDeviceFound: (device : BleDevice) => {
|
||
addDiscoveredDevice(device)
|
||
}
|
||
})
|
||
// #endif
|
||
// #ifndef APP-ANDROID
|
||
await bluetoothSvc.scanDevices({ optionalServices: REQUIRED_SERVICE_UUIDS })
|
||
// #endif
|
||
} else {
|
||
await simulateDiscovery()
|
||
}
|
||
} catch (e) {
|
||
console.log('扫描设备失败', e)
|
||
await simulateDiscovery()
|
||
} finally {
|
||
isScanning.value = false
|
||
if (connectionState.value == 'scanning') {
|
||
connectionState.value = previousState
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function getPreferredDeviceId() : string {
|
||
if (selectedDeviceId.value != '') return selectedDeviceId.value
|
||
const device = connectedDevice.value
|
||
if (device != null) {
|
||
return device.device_mac ?? device.id
|
||
}
|
||
if (state.deviceState.devices.length > 0) {
|
||
const first = state.deviceState.devices[0]
|
||
return first.device_mac ?? first.id
|
||
}
|
||
return ''
|
||
}
|
||
function decodeTimeBuffer(buffer : ArrayBuffer, format : string) : string {
|
||
const arr = new Uint8Array(buffer)
|
||
if (arr.length == 0) return ''
|
||
if (format == 'string') {
|
||
try {
|
||
const text = new TextDecoder().decode(arr).trim()
|
||
if (text != '') return text
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
if (arr.length < 7) return ''
|
||
const year = arr[0] + (arr[1] << 8)
|
||
const month = arr[2]
|
||
const day = arr[3]
|
||
const hour = arr[4]
|
||
const minute = arr[5]
|
||
const second = arr[6]
|
||
if (year <= 0 || month <= 0 || day <= 0) return ''
|
||
return `${year}-${pad2(month)}-${pad2(day)} ${pad2(hour)}:${pad2(minute)}:${pad2(second)}`
|
||
}
|
||
|
||
async function tryReadTimeFromService(deviceId : string, serviceId : string) : Promise<string> {
|
||
if (serviceId == '') {
|
||
return ''
|
||
}
|
||
let charsRaw : Array<BleCharacteristic> = []
|
||
try {
|
||
const raw = await bluetoothSvc.getCharacteristics(deviceId, serviceId)
|
||
charsRaw = raw as Array<BleCharacteristic>
|
||
} catch (e) {
|
||
console.log('获取特征失败', serviceId, e)
|
||
return ''
|
||
}
|
||
if (charsRaw.length == 0) return ''
|
||
for (let i : Int = 0; i < charsRaw.length; i++) {
|
||
const ch = charsRaw[i] as BleCharacteristic
|
||
if (ch == null || ch.uuid == null) continue
|
||
const charUuidLower = ('' + ch.uuid).toLowerCase()
|
||
let format : string = ''
|
||
if (charUuidLower.indexOf('2a2b') != -1 || charUuidLower.indexOf('current') != -1) {
|
||
format = 'current'
|
||
} else if (charUuidLower.indexOf('2a08') != -1 || charUuidLower.indexOf('date') != -1) {
|
||
format = 'datetime'
|
||
} else if (charUuidLower.indexOf('2a0f') != -1 || charUuidLower.indexOf('exact') != -1) {
|
||
format = 'exact'
|
||
} else if (charUuidLower.indexOf('time') != -1) {
|
||
format = 'string'
|
||
}
|
||
if (format == '') continue
|
||
try {
|
||
const buffer = await bluetoothSvc.readCharacteristic(deviceId, serviceId, ch.uuid)
|
||
const parsed = decodeTimeBuffer(buffer, format)
|
||
if (parsed != '') return parsed
|
||
} catch (readErr) {
|
||
console.log('读取时间特征失败', ch.uuid, readErr)
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
async function readDeviceCurrentTime(deviceId : string) : Promise<string> {
|
||
if (bluetoothSvc == null || deviceId == '') return ''
|
||
|
||
let servicesRaw : Array<BleService> = []
|
||
try {
|
||
const raw = await bluetoothSvc.getServices(deviceId)
|
||
servicesRaw = raw as Array<BleService>
|
||
} catch (e) {
|
||
console.log('获取服务列表失败', e)
|
||
return ''
|
||
}
|
||
if (servicesRaw.length == 0) return ''
|
||
const primaryCandidates : Array<string> = []
|
||
const secondaryCandidates : Array<string> = []
|
||
for (let i : Int = 0; i < servicesRaw.length; i++) {
|
||
const svc = servicesRaw[i] as BleService
|
||
if (svc == null || svc.uuid == null) continue
|
||
const uuidLower = ('' + svc.uuid).toLowerCase()
|
||
if (uuidLower.indexOf('1805') != -1 || uuidLower.indexOf('time') != -1) {
|
||
primaryCandidates.push(svc.uuid)
|
||
} else {
|
||
secondaryCandidates.push(svc.uuid)
|
||
}
|
||
}
|
||
const orderedServices = primaryCandidates.concat(secondaryCandidates)
|
||
for (let i : Int = 0; i < orderedServices.length; i++) {
|
||
const serviceId = orderedServices[i]
|
||
const value = await tryReadTimeFromService(deviceId, serviceId)
|
||
if (value != '') return value
|
||
}
|
||
return ''
|
||
}
|
||
function resetDeviceInfo() : void {
|
||
hardwareVersion.value = '--'
|
||
softwareVersion.value = '--'
|
||
deviceTimeDisplay.value = '--'
|
||
deviceInfoLoading.value = false
|
||
batteryLevel.value = null
|
||
isCharging.value = false
|
||
pingInFlight.value = false
|
||
pingStatusMessage.value = '未测试'
|
||
pingStatusTone.value = 'idle'
|
||
pingLastSuccessAt.value = ''
|
||
}
|
||
|
||
async function fetchDeviceInfo(deviceId : string) {
|
||
if (deviceId == '') return
|
||
protocolHandler.setConnectionParameters(deviceId, '', '', '')
|
||
try {
|
||
await protocolHandler.prepareControlChannel()
|
||
} catch (prepErr) {
|
||
console.warn('[healthble] prepareControlChannel during fetchDeviceInfo failed', prepErr)
|
||
}
|
||
deviceInfoLoading.value = true
|
||
try {
|
||
let swVersion = ''
|
||
|
||
// normalizeVersion: safely produce a trimmed string from various return types
|
||
function normalizeVersion(v : any) : string {
|
||
if (v == null) return ''
|
||
if (typeof v == 'string') {
|
||
const s = v.trim()
|
||
if (s == '' || /^\[object\s+.+\]$/.test(s)) return ''
|
||
return s
|
||
}
|
||
// numeric arrays like [65,66,67] or ArrayBuffer views
|
||
try {
|
||
if (v instanceof Uint8Array) {
|
||
const s = new TextDecoder().decode(v).trim()
|
||
return s
|
||
}
|
||
if (v != null && v instanceof ArrayBuffer) {
|
||
const s = new TextDecoder().decode(new Uint8Array(v as Array<number>)).trim()
|
||
return s
|
||
}
|
||
if (Array.isArray(v)) {
|
||
// numeric byte arrays
|
||
if (v.length > 0 && v.every((x : any) => typeof x == 'number')) {
|
||
const s = new TextDecoder().decode(new Uint8Array(v as Array<number>)).trim()
|
||
return s
|
||
}
|
||
// array of strings
|
||
if (v.length > 0 && v.every((x : any) => typeof x == 'string')) {
|
||
return v.join('').trim()
|
||
}
|
||
// array of objects: try to extract known fields from first element
|
||
if (v.length > 0 && typeof v[0] == 'object') {
|
||
const first = v[0] as UTSJSONObject
|
||
const candidates = ['version', 'sw', 'swVersion', 'software', 'firmware', 'fw']
|
||
for (let i = 0; i < candidates.length; i++) {
|
||
const key = candidates[i]
|
||
const value = first[key] ?? null
|
||
if (value != null) return normalizeVersion(value)
|
||
}
|
||
return ''
|
||
}
|
||
}
|
||
} catch (e) { /* ignore decode errors */ }
|
||
// If it's an object, try to extract common fields instead of stringifying
|
||
if (typeof v == 'object') {
|
||
const candidates = ['version', 'sw', 'swVersion', 'software', 'firmware', 'fw']
|
||
for (let i = 0; i < candidates.length; i++) {
|
||
const k = candidates[i]
|
||
const value = (v as UTSJSONObject).get(k) as string | null
|
||
if (value != null) {
|
||
return normalizeVersion(value)
|
||
}
|
||
}
|
||
return '' // avoid '[object Object]'
|
||
}
|
||
try {
|
||
const s = ('' + v).trim()
|
||
if (s == '' || /^\[object\s+.+\]$/.test(s)) return ''
|
||
return s
|
||
} catch (e) { return '' }
|
||
}
|
||
try {
|
||
console.log('读取软件版本: 0', swVersion)
|
||
swVersion = await protocolHandler.testVersionInfo(false)
|
||
console.log('读取软件版本: 1', swVersion)
|
||
|
||
} catch (swError) {
|
||
console.log('读取软件版本失败', swError)
|
||
}
|
||
// Debug: log raw return value shapes to diagnose 'object,object' UI
|
||
try {
|
||
console.log('[healthble] raw swVersion:', swVersion)
|
||
console.log('[healthble] swVersion type:', typeof swVersion, 'isArray=', Array.isArray(swVersion))
|
||
if (swVersion != null && typeof swVersion == 'object' && !Array.isArray(swVersion)) {
|
||
// show keys for object
|
||
const swObj = swVersion as UTSJSONObject
|
||
console.log('[healthble] swVersion:', swObj)
|
||
}
|
||
} catch (e) { /* ignore logging errors */ }
|
||
const swStr = normalizeVersion(swVersion)
|
||
if (swStr == '') {
|
||
softwareVersion.value = '--'
|
||
} else {
|
||
softwareVersion.value = swStr
|
||
}
|
||
let hwVersion = ''
|
||
try {
|
||
console.log('读取硬件版本: 0', hwVersion)
|
||
hwVersion = await protocolHandler.testVersionInfo(true)
|
||
console.log('读取硬件版本: 1', hwVersion)
|
||
} catch (hwError) {
|
||
console.log('读取硬件版本失败', hwError)
|
||
}
|
||
// Debug: log raw hwVersion shape
|
||
try {
|
||
console.log('[healthble] raw hwVersion:', hwVersion)
|
||
console.log('[healthble] hwVersion type:', typeof hwVersion, 'isArray=', Array.isArray(hwVersion))
|
||
if (hwVersion != null && typeof hwVersion == 'object' && !Array.isArray(hwVersion)) {
|
||
const hwObj = hwVersion as UTSJSONObject
|
||
console.log('[healthble] hwVersion:', hwObj)
|
||
}
|
||
} catch (e) { }
|
||
const hwStr = normalizeVersion(hwVersion)
|
||
if (hwStr == '') {
|
||
hardwareVersion.value = '--'
|
||
} else {
|
||
hardwareVersion.value = hwStr
|
||
}
|
||
|
||
// 检查固件升级
|
||
if (hwStr != '--' && swStr != '--') {
|
||
const hwVersion = hwStr as string
|
||
const swVersion = swStr as string
|
||
try {
|
||
const firmwareInfo = await dfuTool.checkFirmwareUpdate(hwVersion, swVersion)
|
||
if (firmwareInfo != null) {
|
||
firmwareUpdateAvailable.value = true
|
||
firmwareUpdateInfo.value = firmwareInfo
|
||
}
|
||
} catch (error) {
|
||
console.error('检查固件升级失败:', error)
|
||
firmwareUpdateAvailable.value = false
|
||
firmwareUpdateInfo.value = null
|
||
}
|
||
}
|
||
|
||
let deviceTime = ''
|
||
try {
|
||
deviceTime = await readDeviceCurrentTime(deviceId)
|
||
} catch (timeError) {
|
||
console.log('读取设备时间失败', timeError)
|
||
}
|
||
if (deviceTime == '') {
|
||
deviceTimeDisplay.value = '--'
|
||
} else {
|
||
deviceTimeDisplay.value = deviceTime
|
||
}
|
||
let battery = -1
|
||
try {
|
||
battery = await protocolHandler.testBatteryLevel()
|
||
} catch (batteryError) {
|
||
console.log('读取电量失败', batteryError)
|
||
}
|
||
if (battery >= 0) {
|
||
batteryLevel.value = battery
|
||
} else {
|
||
batteryLevel.value = null
|
||
}
|
||
} finally {
|
||
deviceInfoLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 开始固件升级
|
||
async function startFirmwareUpdate() {
|
||
if (firmwareUpdateInfo.value == null) return
|
||
|
||
firmwareUpdateInProgress.value = true
|
||
const progressObj = new UTSJSONObject()
|
||
progressObj.set('stage', 'downloading')
|
||
progressObj.set('progress', 0)
|
||
progressObj.set('message', '准备升级...')
|
||
firmwareUpdateProgress.value = progressObj as DFUProgress
|
||
|
||
try {
|
||
const dfuWithProgress = new DFUTool((progress : DFUProgress) => {
|
||
firmwareUpdateProgress.value = progress
|
||
console.log(`DFU进度: ${progress.stage} - ${progress.progress}% - ${progress.message}`)
|
||
})
|
||
|
||
const deviceId = getPreferredDeviceId()
|
||
if (deviceId == '') {
|
||
throw new Error('未找到连接的设备')
|
||
}
|
||
|
||
const success = await dfuWithProgress.performFirmwareUpdate(firmwareUpdateInfo.value, deviceId)
|
||
|
||
if (success) {
|
||
uni.showToast({ title: '固件升级成功', icon: 'success' })
|
||
// 重新获取设备信息
|
||
await fetchDeviceInfo(deviceId)
|
||
} else {
|
||
uni.showToast({ title: '固件升级失败', icon: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('固件升级过程出错:', error)
|
||
uni.showToast({ title: '升级失败', icon: 'error' })
|
||
} finally {
|
||
firmwareUpdateInProgress.value = false
|
||
firmwareUpdateProgress.value = null
|
||
}
|
||
}
|
||
|
||
|
||
async function connectSelectedDevice() {
|
||
const deviceId = getPreferredDeviceId()
|
||
if (deviceId == '') {
|
||
uni.showToast({ title: '请先扫描并选择设备', icon: 'none' })
|
||
return
|
||
}
|
||
connectionState.value = 'connecting'
|
||
try {
|
||
if (bluetoothSvc != null) {
|
||
// #ifdef APP-ANDROID
|
||
const androidConnectOptions = {} as BleConnectOptionsExt
|
||
await bluetoothSvc.connectDevice(deviceId, 'standard', androidConnectOptions)
|
||
// #endif
|
||
// #ifndef APP-ANDROID
|
||
const params = new UTSJSONObject()
|
||
params.set('deviceId', deviceId)
|
||
params.set('options', null)
|
||
await bluetoothSvc.connectDevice(params)
|
||
// #endif
|
||
}
|
||
connectionState.value = 'connected'
|
||
lastSyncTime.value = formatFullTime(new Date())
|
||
if (signalRssi.value == null) {
|
||
signalRssi.value = -55
|
||
}
|
||
for (let i : Int = 0; i < state.deviceState.devices.length; i++) {
|
||
const device = state.deviceState.devices[i]
|
||
const candidateId = device.device_mac ?? device.id
|
||
if (candidateId == deviceId) {
|
||
setCurrentDevice(device)
|
||
break
|
||
}
|
||
}
|
||
selectedDeviceId.value = deviceId
|
||
// Ensure protocol handler knows which device we're working with before attempting sync
|
||
try {
|
||
protocolHandler.setConnectionParameters(deviceId, '', '', '')
|
||
} catch (e) { console.warn('[healthble] failed to set protocolHandler connection params', e) }
|
||
try {
|
||
await protocolHandler.prepareControlChannel()
|
||
} catch (controlErr) {
|
||
console.warn('[healthble] prepareControlChannel failed', controlErr)
|
||
}
|
||
// Attempt to synchronize device time (CTS / vendor fallback) immediately after connect
|
||
try {
|
||
// const syncResult = await protocolHandler.synchronizeOnConnect()
|
||
// console.log('[healthble] synchronizeOnConnect result:', syncResult)
|
||
// if (syncResult == 'timeSynced') {
|
||
// try {
|
||
// const dt = await readDeviceCurrentTime(deviceId)
|
||
// if (dt != '') {
|
||
// deviceTimeDisplay.value = dt
|
||
// }
|
||
// } catch (e) {
|
||
// console.log('[healthble] readDeviceCurrentTime after sync failed', e)
|
||
// }
|
||
// }
|
||
} catch (syncErr) {
|
||
console.log('[healthble] synchronizeOnConnect error:', syncErr)
|
||
}
|
||
// Subscribe to health notifications (device will push notify packets)
|
||
try {
|
||
if (!healthSubscribed.value) {
|
||
await protocolHandler.subscribeHealthNotifications((packet : HealthData) => {
|
||
try {
|
||
handleHealthData(packet)
|
||
} catch (notifyError) {
|
||
console.warn('[healthble] health notify handler error', notifyError)
|
||
}
|
||
})
|
||
healthSubscribed.value = true
|
||
}
|
||
} catch (e) { console.warn('[healthble] subscribeHealthNotifications failed', e) }
|
||
await fetchDeviceInfo(deviceId)
|
||
|
||
// 获取设备的服务和特征信息并保存到数据库
|
||
try {
|
||
const services = await bluetoothSvc.getServices(deviceId)
|
||
const deviceInfo = new UTSJSONObject()
|
||
deviceInfo.set('services', JSON.stringify(services))
|
||
deviceInfo.set('characteristics', new UTSJSONObject())
|
||
|
||
// 为每个服务获取特征
|
||
for (let i = 0; i < services.length; i++) {
|
||
const service = services[i] as BleService
|
||
if (service != null && service.uuid != null) {
|
||
try {
|
||
const characteristics = await bluetoothSvc.getCharacteristics(deviceId, service.uuid)
|
||
const charsObj = new UTSJSONObject()
|
||
charsObj.set(service.uuid ?? '', JSON.stringify(characteristics))
|
||
deviceInfo.set('characteristics', charsObj)
|
||
} catch (charError) {
|
||
console.warn('[healthble] 获取服务特征失败', service.uuid, charError)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 构造设备数据
|
||
const deviceData = new UTSJSONObject()
|
||
deviceData.set('device_type', 'bluetooth_health_monitor')
|
||
deviceData.set('device_name', deviceName.value)
|
||
deviceData.set('device_mac', deviceId)
|
||
deviceData.set('extra', JSON.stringify(deviceInfo))
|
||
|
||
// 保存到数据库
|
||
const saveResult = await bindNewDevice(deviceData)
|
||
if (saveResult) {
|
||
console.log('[healthble] 设备信息已保存到数据库')
|
||
} else {
|
||
console.warn('[healthble] 保存设备信息到数据库失败')
|
||
}
|
||
} catch (deviceInfoError) {
|
||
console.warn('[healthble] 获取设备服务信息失败', deviceInfoError)
|
||
// 不影响连接流程,继续执行
|
||
}
|
||
|
||
uni.showToast({ title: '设备已连接', icon: 'success' })
|
||
pingStatusMessage.value = '已连接,可发送 Ping'
|
||
pingStatusTone.value = 'idle'
|
||
pingLastSuccessAt.value = ''
|
||
if (!isMonitoring.value) {
|
||
startMonitoring()
|
||
}
|
||
} catch (e) {
|
||
console.log('连接设备失败', e)
|
||
connectionState.value = 'disconnected'
|
||
resetDeviceInfo()
|
||
uni.showToast({ title: '连接失败', icon: 'error' })
|
||
}
|
||
}
|
||
|
||
function assignCurrentDevice(deviceId : string) {
|
||
for (let i : Int = 0; i < state.deviceState.devices.length; i++) {
|
||
const device = state.deviceState.devices[i]
|
||
const candidateId = device.device_mac ?? device.id
|
||
if (candidateId == deviceId) {
|
||
setCurrentDevice(device)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
async function disconnectDevice() {
|
||
const deviceId = getPreferredDeviceId()
|
||
if (deviceId == '') {
|
||
connectionState.value = 'disconnected'
|
||
return
|
||
}
|
||
try {
|
||
if (bluetoothSvc != null) {
|
||
// #ifdef APP-ANDROID
|
||
await bluetoothSvc.disconnectDevice(deviceId, 'standard')
|
||
// #endif
|
||
// #ifndef APP-ANDROID
|
||
await bluetoothSvc.disconnectDevice(deviceId)
|
||
// #endif
|
||
}
|
||
} catch (e) {
|
||
console.log('断开设备失败', e)
|
||
}
|
||
// Ensure we unsubscribe from health notifications when disconnecting
|
||
try {
|
||
if (protocolHandler != null) {
|
||
await protocolHandler.unsubscribeHealthNotifications()
|
||
}
|
||
} catch (e) { console.warn('[healthble] unsubscribeHealthNotifications on disconnect failed', e) }
|
||
healthSubscribed.value = false
|
||
connectionState.value = 'disconnected'
|
||
resetDeviceInfo()
|
||
uni.showToast({ title: '已断开', icon: 'none' })
|
||
}
|
||
|
||
async function sendPingCommand() {
|
||
if (connectionState.value != 'connected') {
|
||
uni.showToast({ title: '请先连接设备', icon: 'none' })
|
||
return
|
||
}
|
||
if (pingInFlight.value) return
|
||
pingInFlight.value = true
|
||
pingStatusTone.value = 'idle'
|
||
pingStatusMessage.value = '等待应答…'
|
||
try {
|
||
const result : PingResult = await protocolHandler.sendPing()
|
||
pingStatusTone.value = 'success'
|
||
pingStatusMessage.value = `成功 ${result.latencyMs} ms`
|
||
pingLastSuccessAt.value = formatFullTime(new Date())
|
||
uni.showToast({ title: 'Ping成功', icon: 'success' })
|
||
} catch (err : any) {
|
||
const message = err != null && typeof err == 'object' && err.message != null ? ('' + err.message) : 'Ping失败'
|
||
pingStatusTone.value = 'error'
|
||
if (message.indexOf('timeout') != -1) {
|
||
pingStatusMessage.value = '超时未响应'
|
||
} else {
|
||
pingStatusMessage.value = message
|
||
}
|
||
console.warn('[healthble] sendPingCommand failed', err)
|
||
uni.showToast({ title: 'Ping失败', icon: 'none' })
|
||
} finally {
|
||
pingInFlight.value = false
|
||
}
|
||
}
|
||
|
||
|
||
function onConnectPressed() {
|
||
if (connectionState.value == 'connecting') return
|
||
if (connectionState.value == 'connected') {
|
||
disconnectDevice()
|
||
} else {
|
||
connectSelectedDevice()
|
||
}
|
||
}
|
||
function connectFromDiscovery(device : UTSJSONObject) {
|
||
const deviceId = device.getString('deviceId') ?? ''
|
||
if (deviceId != '') {
|
||
selectedDeviceId.value = deviceId
|
||
onConnectPressed()
|
||
}
|
||
}
|
||
|
||
async function loadHistory() {
|
||
isLoadingHistory.value = true
|
||
try {
|
||
const params : SensorDataParams = { limit: 6 }
|
||
const device = connectedDevice.value
|
||
if (device != null) {
|
||
params.device_id = device.id
|
||
}
|
||
const result = await SenseDataService.getMeasurements(params)
|
||
if (result.error == null && result.data != null) {
|
||
const records = result.data as Array<SensorMeasurement>
|
||
historyMeasurements.value = records
|
||
} else {
|
||
historyMeasurements.value = []
|
||
}
|
||
} catch (e) {
|
||
console.log('加载历史数据异常', e)
|
||
historyMeasurements.value = []
|
||
} finally {
|
||
isLoadingHistory.value = false
|
||
}
|
||
}
|
||
async function refreshHistory() {
|
||
await loadHistory()
|
||
}
|
||
|
||
function formatHistoryValue(item : SensorMeasurement) : string {
|
||
const raw = item.raw_data
|
||
if (raw == null) return '--'
|
||
const type = item.measurement_type ?? ''
|
||
if (type == 'heart_rate') {
|
||
const bpm = raw.getNumber('bpm') ?? 0
|
||
return `${bpm} bpm`
|
||
}
|
||
if (type == 'spo2') {
|
||
const spo2 = raw.getNumber('spo2') ?? 0
|
||
return `${spo2}%`
|
||
}
|
||
if (type == 'steps') {
|
||
const count = raw.getNumber('count') ?? 0
|
||
return `${count} 步`
|
||
}
|
||
if (type == 'temp') {
|
||
const temp = raw.getNumber('temp') ?? 0
|
||
return `${temp.toFixed(1)}℃`
|
||
}
|
||
if (type == 'bp') {
|
||
const sys = raw.getNumber('systolic') ?? 0
|
||
const dia = raw.getNumber('diastolic') ?? 0
|
||
return `${sys}/${dia} mmHg`
|
||
}
|
||
return '--'
|
||
}
|
||
|
||
function formatHistoryTime(timeStr : string) : string {
|
||
if (timeStr == '') return '--'
|
||
const date = new Date(timeStr)
|
||
const day = (date.getMonth() + 1).toString().padStart(2, '0') + '-' + date.getDate().toString().padStart(2, '0')
|
||
const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0')
|
||
return `${day} ${time}`
|
||
}
|
||
|
||
function startRecording() {
|
||
if (isRecording.value) return
|
||
isRecording.value = true
|
||
recordingSeconds.value = 0
|
||
recordingTimer = setInterval(() => {
|
||
recordingSeconds.value = recordingSeconds.value + 1
|
||
}, 1000) as number
|
||
}
|
||
|
||
function triggerSOS() {
|
||
uni.showModal({
|
||
title: '确认 SOS',
|
||
content: '确认向护理人员发送紧急报警?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
currentAnomaly.value = '已发出SOS,等待响应…'
|
||
uni.showToast({ title: 'SOS已发送', icon: 'success' })
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function openDiagnostics() {
|
||
uni.showToast({ title: '诊断日志即将集成', icon: 'none' })
|
||
}
|
||
|
||
function handleConnectionEvent(payload : BleEventPayload | null) {
|
||
if (payload == null) return
|
||
const stateValue = payload.state ?? 0
|
||
if (stateValue == 2) {
|
||
connectionState.value = 'connected'
|
||
if (payload.device != null) {
|
||
const deviceInfo = payload.device
|
||
const deviceId = deviceInfo?.deviceId ?? ''
|
||
if (deviceId != '') {
|
||
selectedDeviceId.value = deviceId
|
||
fetchDeviceInfo(deviceId)
|
||
}
|
||
}
|
||
if (!isMonitoring.value) startMonitoring()
|
||
} else if (stateValue == 0) {
|
||
connectionState.value = 'disconnected'
|
||
resetDeviceInfo()
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
function convertToSample(json : UTSJSONObject) : HealthSample | null {
|
||
try {
|
||
const heartRate = json.getNumber('heart_rate') ?? json.getNumber('bpm') ?? 0
|
||
const spo2 = json.getNumber('spo2') ?? json.getNumber('oxygen') ?? 0
|
||
const steps = json.getNumber('steps') ?? json.getNumber('step') ?? 0
|
||
const speed = json.getNumber('speed') ?? json.getNumber('pace') ?? 0
|
||
const timestamp = json.getNumber('timestamp') ?? Date.now()
|
||
|
||
return {
|
||
heartRate: heartRate,
|
||
spo2: spo2,
|
||
steps: steps,
|
||
speed: speed,
|
||
timestamp: new Date(timestamp)
|
||
}
|
||
} catch (e) {
|
||
console.log('转换样本数据失败', e)
|
||
return null
|
||
}
|
||
}
|
||
|
||
function handleDataEvent(payload : BleEventPayload | null) {
|
||
if (payload == null) return
|
||
try {
|
||
const rawData = payload.data
|
||
let jsonStr = ''
|
||
if (typeof rawData == 'string') {
|
||
jsonStr = rawData as string
|
||
} else if (rawData instanceof ArrayBuffer) {
|
||
const decoder = new TextDecoder()
|
||
jsonStr = decoder.decode(new Uint8Array(rawData as ArrayBuffer))
|
||
}
|
||
const parsed = JSON.parse(jsonStr) as UTSJSONObject
|
||
const sample = convertToSample(parsed)
|
||
if (sample != null) {
|
||
applySample(sample, true)
|
||
}
|
||
|
||
} catch (e) {
|
||
console.log('解析实时蓝牙数据失败', e)
|
||
}
|
||
}
|
||
function registerBleListeners() {
|
||
if (bluetoothSvc == null) return
|
||
try {
|
||
bluetoothSvc.on('deviceFound', (payload : BleEventPayload) => {
|
||
try {
|
||
if (payload != null && payload.device != null) {
|
||
const device = payload.device as BleDevice
|
||
addDiscoveredDevice(device)
|
||
}
|
||
} catch (e) {
|
||
console.log('处理deviceFound失败', e)
|
||
}
|
||
})
|
||
} catch (e) {
|
||
console.log('注册deviceFound事件失败', e)
|
||
}
|
||
try {
|
||
bluetoothSvc.on('connectionStateChanged', (payload : BleEventPayload) => {
|
||
handleConnectionEvent(payload)
|
||
})
|
||
} catch (e) {
|
||
console.log('注册connectionStateChanged失败', e)
|
||
}
|
||
try {
|
||
bluetoothSvc.on('dataReceived', (payload : BleEventPayload) => {
|
||
handleDataEvent(payload)
|
||
})
|
||
} catch (e) {
|
||
// 某些平台暂无 dataReceived 事件,忽略
|
||
}
|
||
|
||
}
|
||
const connectionSubtitle = computed<string>(() => {
|
||
const name = deviceName.value
|
||
switch (connectionState.value) {
|
||
case 'connected':
|
||
let subtitle = '已准备好采集实时数据'
|
||
if (useSimulation.value) {
|
||
subtitle = '正在使用模拟数据'
|
||
}
|
||
if (batteryText.value != null && batteryText.value != '--') {
|
||
subtitle += ` • ${batteryText.value as string}`
|
||
}
|
||
return subtitle
|
||
case 'connecting':
|
||
return `正在连接 ${name}…`
|
||
case 'scanning':
|
||
return '请保持设备靠近并开启蓝牙'
|
||
default:
|
||
let defaultSubtitle = '点击扫描按钮搜索附近的健康手环'
|
||
if (useSimulation.value) {
|
||
defaultSubtitle = '正在使用模拟数据演示'
|
||
}
|
||
return defaultSubtitle
|
||
}
|
||
})
|
||
|
||
function unregisterBleListeners() {
|
||
if (bluetoothSvc == null) return
|
||
try {
|
||
|
||
bluetoothSvc.off('deviceFound', null)
|
||
|
||
} catch (e) { }
|
||
try {
|
||
|
||
bluetoothSvc.off('connectionStateChanged', null)
|
||
|
||
} catch (e) { }
|
||
try {
|
||
|
||
bluetoothSvc.off('dataReceived', null)
|
||
|
||
} catch (e) { }
|
||
}
|
||
function formatRssi(rssi : number) : string {
|
||
return `${describeSignal(rssi)} (${rssi} dBm)`
|
||
}
|
||
|
||
|
||
|
||
onLoad((op : OnLoadOptions) => {
|
||
console.log(op)
|
||
loadHistory()
|
||
})
|
||
|
||
onMounted(() => {
|
||
registerBleListeners()
|
||
applySample(generateSample(), false)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopSimulation()
|
||
if (recordingTimer != null) {
|
||
clearInterval(recordingTimer as number)
|
||
recordingTimer = null
|
||
}
|
||
unregisterBleListeners()
|
||
})
|
||
|
||
watch(activeMetric, (metric : string) => {
|
||
chartOption.value = createChartOption(metric)
|
||
})
|
||
|
||
watch(connectedDevice, (device : DeviceInfo | null) => {
|
||
if (device != null) {
|
||
selectedDeviceId.value = device.device_mac ?? device.id
|
||
loadHistory()
|
||
const candidateId = device.device_mac ?? device.id
|
||
if (candidateId != null && candidateId != '' && connectionState.value == 'connected') {
|
||
console.log('已连接设备变更,获取设备信息', candidateId)
|
||
fetchDeviceInfo(candidateId)
|
||
}
|
||
}
|
||
if (device == null) {
|
||
resetDeviceInfo()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.healthble-page {
|
||
flex: 1;
|
||
background-color: #f5f7fb;
|
||
padding: 24rpx 24rpx 120rpx 24rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.status-banner {
|
||
background: linear-gradient(135deg, #3b82f6, #06b6d4);
|
||
padding: 32rpx;
|
||
border-radius: 24rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.status-main {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-icon {
|
||
font-size: 48rpx;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.status-expand-icon {
|
||
font-size: 32rpx;
|
||
margin-left: 16rpx;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.status-texts {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
}
|
||
|
||
.status-title {
|
||
font-size: 34rpx;
|
||
font-weight: bold;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
|
||
.status-subtitle {
|
||
font-size: 26rpx;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.status-battery {
|
||
font-size: 24rpx;
|
||
margin-top: 4rpx;
|
||
}
|
||
|
||
.status-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-btn {
|
||
padding: 16rpx 28rpx;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
color: #fff;
|
||
border-radius: 999rpx;
|
||
font-size: 26rpx;
|
||
margin-left: 16rpx;
|
||
}
|
||
|
||
.status-btn-first {
|
||
margin-left: 0;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.device-card {
|
||
background: #fff;
|
||
border-radius: 24rpx;
|
||
padding: 28rpx;
|
||
box-shadow: 0 12rpx 34rpx rgba(59, 130, 246, 0.08);
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.device-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.device-summary {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.device-name {
|
||
font-size: 34rpx;
|
||
font-weight: bold;
|
||
color: #111827;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.device-battery {
|
||
font-size: 26rpx;
|
||
color: #059669;
|
||
}
|
||
|
||
.device-action {
|
||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||
color: #fff;
|
||
padding: 18rpx 36rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.device-meta {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.meta-item {
|
||
margin-right: 24rpx;
|
||
margin-bottom: 12rpx;
|
||
color: #4b5563;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.firmware-update {
|
||
color: #059669;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.firmware-progress {
|
||
color: #d97706;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.firmware-btn {
|
||
padding: 16rpx 24rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 26rpx;
|
||
margin-top: 4rpx;
|
||
margin-left: 16rpx;
|
||
}
|
||
|
||
.firmware-btn.primary {
|
||
background: #059669;
|
||
color: #fff;
|
||
}
|
||
|
||
.firmware-btn.secondary {
|
||
background: #d97706;
|
||
color: #fff;
|
||
}
|
||
|
||
.scan-btn {
|
||
background: #eef2ff;
|
||
color: #1d4ed8;
|
||
padding: 16rpx 24rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 26rpx;
|
||
margin-top: 4rpx;
|
||
}
|
||
.ping-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-top: 16rpx;
|
||
}
|
||
.ping-btn {
|
||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||
color: #fff;
|
||
padding: 16rpx 28rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 26rpx;
|
||
}
|
||
.ping-status {
|
||
margin-left: 16rpx;
|
||
font-size: 24rpx;
|
||
color: #4b5563;
|
||
}
|
||
.ping-status.ping-idle {
|
||
color: #4b5563;
|
||
}
|
||
.ping-status.ping-success {
|
||
color: #059669;
|
||
}
|
||
.ping-status.ping-error {
|
||
color: #dc2626;
|
||
}
|
||
.ping-meta {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 24rpx;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.device-discovery {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.discovery-chip {
|
||
background: #f3f4f6;
|
||
padding: 20rpx;
|
||
border-radius: 20rpx;
|
||
min-width: 220rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-right: 16rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.discovery-chip.active {
|
||
background: #e0f2fe;
|
||
border: 2rpx solid #38bdf8;
|
||
}
|
||
|
||
.chip-name {
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
color: #1f2937;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.chip-sub {
|
||
font-size: 24rpx;
|
||
color: #4b5563;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.chip-connect {
|
||
background: #2563eb;
|
||
color: #fff;
|
||
padding: 12rpx 20rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.quick-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.action-item {
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
padding: 24rpx 12rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
box-shadow: 0 10rpx 20rpx rgba(15, 23, 42, 0.04);
|
||
width: 48%;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.action-icon {
|
||
font-size: 42rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.action-label {
|
||
font-size: 24rpx;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.chart-card {
|
||
background: #fff;
|
||
border-radius: 24rpx;
|
||
padding: 24rpx;
|
||
margin-bottom: 28rpx;
|
||
box-shadow: 0 12rpx 28rpx rgba(59, 130, 246, 0.08);
|
||
}
|
||
|
||
.chart-header {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.chart-title-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.chart-controls {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.time-config,
|
||
.interval-config {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.time-config {
|
||
margin-right: 24rpx;
|
||
}
|
||
|
||
.interval-config {
|
||
margin-right: 0;
|
||
}
|
||
|
||
.config-label {
|
||
font-size: 24rpx;
|
||
color: #6b7280;
|
||
margin-right: 8rpx;
|
||
}
|
||
|
||
.config-btn {
|
||
padding: 6rpx 12rpx;
|
||
border-radius: 12rpx;
|
||
background: #f3f4f6;
|
||
font-size: 22rpx;
|
||
color: #374151;
|
||
border: 1rpx solid #d1d5db;
|
||
margin-right: 8rpx;
|
||
}
|
||
|
||
.config-btn-last {
|
||
margin-right: 0;
|
||
}
|
||
|
||
.config-btn.active {
|
||
background: #dbeafe;
|
||
color: #1d4ed8;
|
||
border-color: #3b82f6;
|
||
}
|
||
|
||
.chart-tabs {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.chart-tab {
|
||
padding: 12rpx 24rpx;
|
||
border-radius: 999rpx;
|
||
background: #f3f4f6;
|
||
font-size: 24rpx;
|
||
color: #374151;
|
||
margin-right: 12rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.chart-tab.active {
|
||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||
color: #fff;
|
||
}
|
||
|
||
.chart-view {
|
||
width: 100%;
|
||
height: 500rpx;
|
||
}
|
||
|
||
.metrics-card {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.metric {
|
||
background: #fff;
|
||
border-radius: 24rpx;
|
||
padding: 24rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 48%;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.metric-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.metric-icon {
|
||
font-size: 34rpx;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.metric-label {
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.metric-value-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.metric-value {
|
||
font-size: 44rpx;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.metric-unit {
|
||
font-size: 24rpx;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.metric-extreme {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.metric-range {
|
||
color: #6b7280;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.history-card {
|
||
background: #fff;
|
||
border-radius: 24rpx;
|
||
padding: 24rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.history-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.history-title {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.history-more {
|
||
padding: 12rpx 24rpx;
|
||
border-radius: 999rpx;
|
||
background: #f3f4f6;
|
||
font-size: 24rpx;
|
||
color: #2563eb;
|
||
}
|
||
|
||
.history-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.history-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #f9fafb;
|
||
padding: 18rpx 24rpx;
|
||
border-radius: 18rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.history-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.history-type {
|
||
font-size: 26rpx;
|
||
font-weight: bold;
|
||
color: #1f2937;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.history-value {
|
||
font-size: 24rpx;
|
||
color: #4b5563;
|
||
}
|
||
|
||
.history-time {
|
||
font-size: 24rpx;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.history-empty {
|
||
padding: 32rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.empty-text {
|
||
color: #6b7280;
|
||
font-size: 24rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.alert-banner {
|
||
background: #fef3c7;
|
||
color: #b45309;
|
||
padding: 20rpx 24rpx;
|
||
border-radius: 18rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.alert-icon {
|
||
font-size: 34rpx;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.alert-text {
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.recording-panel {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: #111827;
|
||
padding: 28rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-top-left-radius: 24rpx;
|
||
border-top-right-radius: 24rpx;
|
||
color: #fff;
|
||
}
|
||
|
||
.recording-title {
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.recording-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.recording-btn {
|
||
flex: 1;
|
||
padding: 18rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 26rpx;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.recording-btn.primary {
|
||
background: #ef4444;
|
||
color: #fff;
|
||
}
|
||
|
||
.recording-btn.secondary {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: #f9fafb;
|
||
margin-right: 0;
|
||
}
|
||
|
||
@media screen and (min-width: 900px) {
|
||
.action-item {
|
||
width: 23%;
|
||
}
|
||
|
||
.metric {
|
||
width: 48%;
|
||
}
|
||
}
|
||
|
||
@media screen and (max-width: 600px) {
|
||
.action-item {
|
||
width: 100%;
|
||
}
|
||
|
||
.metric {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style> |