- 移除旧的锻炼会话页面和布局文件 - 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示 - 优化锻炼历史页面,增加月度统计卡片和交互式详情查看 - 新增锻炼详情服务,提供心率分析、METs计算等功能 - 更新应用版本至1.0.17并调整iOS后台任务配置 - 添加项目规则文档,明确React Native开发规范
1841 lines
57 KiB
TypeScript
1841 lines
57 KiB
TypeScript
import dayjs from 'dayjs';
|
||
import { AppState, AppStateStatus, NativeModules } from 'react-native';
|
||
import { SimpleEventEmitter } from './SimpleEventEmitter';
|
||
|
||
type HealthDataOptions = {
|
||
startDate: string;
|
||
endDate: string;
|
||
limit?: number;
|
||
};
|
||
|
||
// 锻炼数据类型定义
|
||
export interface WorkoutData {
|
||
id: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
duration: number; // 秒
|
||
workoutActivityType: number;
|
||
workoutActivityTypeString: string;
|
||
totalEnergyBurned?: number; // 千卡
|
||
totalDistance?: number; // 米
|
||
averageHeartRate?: number;
|
||
source: {
|
||
name: string;
|
||
bundleIdentifier: string;
|
||
};
|
||
metadata: Record<string, any>;
|
||
}
|
||
|
||
export interface HeartRateSample {
|
||
id: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
value: number;
|
||
source?: {
|
||
name: string;
|
||
bundleIdentifier: string;
|
||
};
|
||
metadata?: Record<string, any>;
|
||
}
|
||
|
||
// 锻炼记录查询选项
|
||
export interface WorkoutOptions extends HealthDataOptions {
|
||
limit?: number; // 默认10条
|
||
}
|
||
|
||
// 锻炼活动类型枚举
|
||
export enum WorkoutActivityType {
|
||
AmericanFootball = 1,
|
||
Archery = 2,
|
||
AustralianFootball = 3,
|
||
Badminton = 4,
|
||
Baseball = 5,
|
||
Basketball = 6,
|
||
Bowling = 7,
|
||
Boxing = 8,
|
||
Climbing = 9,
|
||
Cricket = 10,
|
||
CrossTraining = 11,
|
||
Curling = 12,
|
||
Cycling = 13,
|
||
Dance = 14,
|
||
DanceInspiredTraining = 15,
|
||
Elliptical = 16,
|
||
EquestrianSports = 17,
|
||
Fencing = 18,
|
||
Fishing = 19,
|
||
FunctionalStrengthTraining = 20,
|
||
Golf = 21,
|
||
Gymnastics = 22,
|
||
Handball = 23,
|
||
Hiking = 24,
|
||
Hockey = 25,
|
||
Hunting = 26,
|
||
Lacrosse = 27,
|
||
MartialArts = 28,
|
||
MindAndBody = 29,
|
||
MixedMetabolicCardioTraining = 30,
|
||
PaddleSports = 31,
|
||
Play = 32,
|
||
PreparationAndRecovery = 33,
|
||
Racquetball = 34,
|
||
Rowing = 35,
|
||
Rugby = 36,
|
||
Running = 37,
|
||
Sailing = 38,
|
||
SkatingSports = 39,
|
||
SnowSports = 40,
|
||
Soccer = 41,
|
||
Softball = 42,
|
||
Squash = 43,
|
||
StairClimbing = 44,
|
||
SurfingSports = 45,
|
||
Swimming = 46,
|
||
TableTennis = 47,
|
||
Tennis = 48,
|
||
TrackAndField = 49,
|
||
TraditionalStrengthTraining = 50,
|
||
Volleyball = 51,
|
||
Walking = 52,
|
||
WaterFitness = 53,
|
||
WaterPolo = 54,
|
||
WaterSports = 55,
|
||
Wrestling = 56,
|
||
Yoga = 57,
|
||
Barre = 58,
|
||
CoreTraining = 59,
|
||
CrossCountrySkiing = 60,
|
||
DownhillSkiing = 61,
|
||
Flexibility = 62,
|
||
HighIntensityIntervalTraining = 63,
|
||
JumpRope = 64,
|
||
Kickboxing = 65,
|
||
Pilates = 66,
|
||
Snowboarding = 67,
|
||
Stairs = 68,
|
||
StepTraining = 69,
|
||
WheelchairWalkPace = 70,
|
||
WheelchairRunPace = 71,
|
||
TaiChi = 72,
|
||
MixedCardio = 73,
|
||
HandCycling = 74,
|
||
DiscSports = 75,
|
||
FitnessGaming = 76,
|
||
CardioDance = 77,
|
||
SocialDance = 78,
|
||
Pickleball = 79,
|
||
Cooldown = 80,
|
||
SwimBikeRun = 82,
|
||
Transition = 83,
|
||
UnderwaterDiving = 84,
|
||
Other = 3000
|
||
}
|
||
|
||
// React Native bridge to native HealthKitManager
|
||
const { HealthKitManager } = NativeModules;
|
||
|
||
// HealthKit权限状态枚举
|
||
export enum HealthPermissionStatus {
|
||
Unknown = 'unknown',
|
||
Authorized = 'authorized',
|
||
Denied = 'denied',
|
||
NotDetermined = 'notDetermined'
|
||
}
|
||
|
||
// 权限状态管理类
|
||
class HealthPermissionManager extends SimpleEventEmitter {
|
||
private permissionStatus: HealthPermissionStatus = HealthPermissionStatus.Unknown;
|
||
private isChecking: boolean = false;
|
||
private lastCheckTime: number = 0;
|
||
private checkInterval: number = 5000; // 5秒检查间隔,避免频繁检查
|
||
private appStateSubscription: any = null;
|
||
|
||
constructor() {
|
||
super();
|
||
this.setupAppStateListener();
|
||
}
|
||
|
||
// 设置应用状态监听
|
||
private setupAppStateListener() {
|
||
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange.bind(this));
|
||
}
|
||
|
||
// 处理应用状态变化
|
||
private handleAppStateChange(nextAppState: AppStateStatus) {
|
||
if (nextAppState === 'active') {
|
||
// 应用回到前台时检查权限状态
|
||
console.log('应用回到前台,检查HealthKit权限状态...');
|
||
this.checkPermissionStatus(true);
|
||
}
|
||
}
|
||
|
||
// 获取当前权限状态
|
||
public getPermissionStatus(): HealthPermissionStatus {
|
||
return this.permissionStatus;
|
||
}
|
||
|
||
// 设置权限状态
|
||
private setPermissionStatus(status: HealthPermissionStatus, shouldEmit: boolean = true) {
|
||
const oldStatus = this.permissionStatus;
|
||
this.permissionStatus = status;
|
||
|
||
if (shouldEmit && oldStatus !== status) {
|
||
console.log(`HealthKit权限状态变化: ${oldStatus} -> ${status}`);
|
||
this.emit('permissionStatusChanged', status, oldStatus);
|
||
}
|
||
}
|
||
|
||
// 检查权限状态(通过尝试读取数据来间接判断)
|
||
public async checkPermissionStatus(forceCheck: boolean = false): Promise<HealthPermissionStatus> {
|
||
const now = Date.now();
|
||
|
||
// 避免频繁检查
|
||
if (!forceCheck && this.isChecking) {
|
||
return this.permissionStatus;
|
||
}
|
||
|
||
if (!forceCheck && (now - this.lastCheckTime) < this.checkInterval) {
|
||
return this.permissionStatus;
|
||
}
|
||
|
||
this.isChecking = true;
|
||
this.lastCheckTime = now;
|
||
|
||
try {
|
||
// 尝试获取简单的步数数据来检测权限
|
||
const today = new Date();
|
||
const options = {
|
||
startDate: dayjs(today).startOf('day').toDate().toISOString(),
|
||
endDate: dayjs(today).endOf('day').toDate().toISOString()
|
||
};
|
||
|
||
const result = await HealthKitManager.getStepCount(options);
|
||
|
||
if (result && result.totalValue !== undefined) {
|
||
// 能够获取数据,说明有权限
|
||
this.setPermissionStatus(HealthPermissionStatus.Authorized);
|
||
} else if (result && result.error) {
|
||
// 有错误返回,可能是权限被拒绝
|
||
this.setPermissionStatus(HealthPermissionStatus.Denied);
|
||
} else {
|
||
// 其他情况
|
||
this.setPermissionStatus(HealthPermissionStatus.Unknown);
|
||
}
|
||
} catch (error) {
|
||
console.log('HealthKit权限检查失败,可能是权限被拒绝:', error);
|
||
this.setPermissionStatus(HealthPermissionStatus.Denied);
|
||
} finally {
|
||
this.isChecking = false;
|
||
}
|
||
|
||
return this.permissionStatus;
|
||
}
|
||
|
||
// 请求权限
|
||
public async requestPermission(): Promise<boolean> {
|
||
try {
|
||
console.log('开始请求HealthKit权限...');
|
||
const result = await HealthKitManager.requestAuthorization();
|
||
|
||
if (result && result.success) {
|
||
console.log('HealthKit权限请求成功');
|
||
this.setPermissionStatus(HealthPermissionStatus.Authorized);
|
||
|
||
// 权限获取成功后触发数据刷新事件
|
||
this.emit('permissionGranted');
|
||
return true;
|
||
} else {
|
||
console.error('HealthKit权限请求失败');
|
||
this.setPermissionStatus(HealthPermissionStatus.Denied);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('HealthKit权限请求出现异常:', error);
|
||
this.setPermissionStatus(HealthPermissionStatus.Denied);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 清理资源
|
||
public destroy() {
|
||
if (this.appStateSubscription) {
|
||
this.appStateSubscription.remove();
|
||
}
|
||
this.removeAllListeners();
|
||
}
|
||
}
|
||
|
||
// 全局权限管理实例
|
||
export const healthPermissionManager = new HealthPermissionManager();
|
||
|
||
// Interface for activity summary data from HealthKit
|
||
export interface HealthActivitySummary {
|
||
activeEnergyBurned: number;
|
||
activeEnergyBurnedGoal: number;
|
||
appleExerciseTime: number;
|
||
appleExerciseTimeGoal: number;
|
||
appleStandHours: number;
|
||
appleStandHoursGoal: number;
|
||
dateComponents: {
|
||
day: number;
|
||
month: number;
|
||
year: number;
|
||
};
|
||
}
|
||
|
||
|
||
// const PERMISSIONS: HealthKitPermissions = {
|
||
// permissions: {
|
||
// read: [
|
||
// AppleHealthKit.Constants.Permissions.StepCount,
|
||
// AppleHealthKit.Constants.Permissions.ActiveEnergyBurned,
|
||
// AppleHealthKit.Constants.Permissions.BasalEnergyBurned,
|
||
// AppleHealthKit.Constants.Permissions.SleepAnalysis,
|
||
// AppleHealthKit.Constants.Permissions.HeartRateVariability,
|
||
// AppleHealthKit.Constants.Permissions.ActivitySummary,
|
||
// AppleHealthKit.Constants.Permissions.OxygenSaturation,
|
||
// AppleHealthKit.Constants.Permissions.HeartRate,
|
||
// AppleHealthKit.Constants.Permissions.Water,
|
||
// // 添加 Apple Exercise Time 和 Apple Stand Time 权限
|
||
// AppleHealthKit.Constants.Permissions.AppleExerciseTime,
|
||
// AppleHealthKit.Constants.Permissions.AppleStandTime,
|
||
// ],
|
||
// write: [
|
||
// // 支持体重写入
|
||
// AppleHealthKit.Constants.Permissions.Weight,
|
||
// // 支持饮水量写入
|
||
// AppleHealthKit.Constants.Permissions.Water,
|
||
// ],
|
||
// },
|
||
// };
|
||
|
||
export type HourlyStepData = {
|
||
hour: number; // 0-23
|
||
steps: number;
|
||
};
|
||
|
||
export type HourlyActivityData = {
|
||
hour: number; // 0-23
|
||
calories: number; // 活动热量
|
||
};
|
||
|
||
export type HourlyExerciseData = {
|
||
hour: number; // 0-23
|
||
minutes: number; // 锻炼分钟数
|
||
};
|
||
|
||
export type HourlyStandData = {
|
||
hour: number; // 0-23
|
||
hasStood: number; // 1表示该小时有站立,0表示没有
|
||
};
|
||
|
||
export type TodayHealthData = {
|
||
activeEnergyBurned: number; // kilocalories
|
||
// 健身圆环数据
|
||
activeCalories: number;
|
||
activeCaloriesGoal: number;
|
||
exerciseMinutes: number;
|
||
exerciseMinutesGoal: number;
|
||
standHours: number;
|
||
standHoursGoal: number;
|
||
heartRate: number | null;
|
||
};
|
||
|
||
// 更新:使用新的权限管理系统
|
||
export async function ensureHealthPermissions(): Promise<boolean> {
|
||
return await healthPermissionManager.requestPermission();
|
||
}
|
||
|
||
// 获取当前权限状态
|
||
export function getHealthPermissionStatus(): HealthPermissionStatus {
|
||
return healthPermissionManager.getPermissionStatus();
|
||
}
|
||
|
||
// 检查权限状态
|
||
export async function checkHealthPermissionStatus(forceCheck: boolean = false): Promise<HealthPermissionStatus> {
|
||
return await healthPermissionManager.checkPermissionStatus(forceCheck);
|
||
}
|
||
|
||
// 日期工具函数
|
||
function createDateRange(date: Date): HealthDataOptions {
|
||
return {
|
||
startDate: dayjs(date).startOf('day').toDate().toISOString(),
|
||
endDate: dayjs(date).endOf('day').toDate().toISOString()
|
||
};
|
||
}
|
||
|
||
// Note: createSleepDateRange and calculateSleepDuration functions removed as unused
|
||
|
||
// 通用错误处理
|
||
function logError(operation: string, error: any): void {
|
||
console.error(`获取${operation}失败:`, error);
|
||
}
|
||
|
||
function logWarning(operation: string, message: string): void {
|
||
console.warn(`${operation}数据${message}`);
|
||
}
|
||
|
||
function logSuccess(operation: string, data: any): void {
|
||
console.log(`${operation}数据:`, data);
|
||
}
|
||
|
||
// 数值验证和转换
|
||
function validateOxygenSaturation(value: any): number | null {
|
||
if (value === undefined || value === null) return null;
|
||
|
||
let numValue = Number(value);
|
||
|
||
// 如果值小于1,可能是小数形式(0.0-1.0),需要转换为百分比
|
||
if (numValue > 0 && numValue < 1) {
|
||
numValue = numValue * 100;
|
||
}
|
||
|
||
// 血氧饱和度通常在0-100之间,验证数据有效性
|
||
if (numValue >= 0 && numValue <= 100) {
|
||
return Number(numValue.toFixed(1));
|
||
}
|
||
|
||
console.warn('血氧饱和度数据异常:', numValue);
|
||
return null;
|
||
}
|
||
|
||
function validateHeartRate(value: any): number | null {
|
||
if (value === undefined || value === null) return null;
|
||
|
||
const numValue = Number(value);
|
||
|
||
// 心率通常在30-200之间,验证数据有效性
|
||
if (numValue >= 30 && numValue <= 200) {
|
||
return Math.round(numValue);
|
||
}
|
||
|
||
console.warn('心率数据异常:', numValue);
|
||
return null;
|
||
}
|
||
|
||
function validateHRVValue(value: any): number | null {
|
||
if (value === undefined || value === null) return null;
|
||
|
||
const numValue = Number(value);
|
||
|
||
// HRV SDNN 正常范围检查
|
||
// 正常范围: 18-76ms,但允许更宽范围 5-150ms 以包含边缘情况
|
||
if (numValue >= 5 && numValue <= 150) {
|
||
// 保留1位小数的精度,避免过度舍入
|
||
return Math.round(numValue * 10) / 10;
|
||
}
|
||
|
||
// 记录异常值用于调试
|
||
console.warn('HRV数据超出合理范围:', {
|
||
value: numValue,
|
||
expectedRange: '5-150ms',
|
||
normalRange: '18-76ms'
|
||
});
|
||
return null;
|
||
}
|
||
|
||
// 健康数据获取函数
|
||
export async function fetchStepCount(date: Date): Promise<number> {
|
||
try {
|
||
const options = createDateRange(date);
|
||
const result = await HealthKitManager.getStepCount(options);
|
||
|
||
if (result && result.totalValue !== undefined) {
|
||
logSuccess('步数', result);
|
||
return Math.round(result.totalValue);
|
||
} else {
|
||
logWarning('步数', '为空或格式错误');
|
||
return 0;
|
||
}
|
||
} catch (error) {
|
||
logError('步数', error);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
|
||
// 使用样本数据获取每小时步数 - 优化版本,减少计算复杂度
|
||
export async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
|
||
try {
|
||
const options = createDateRange(date);
|
||
const result = await HealthKitManager.getDailyStepCountSamples(options);
|
||
|
||
if (result && result.data && Array.isArray(result.data)) {
|
||
logSuccess('每小时步数样本', result);
|
||
|
||
// 优化:使用更高效的数据结构
|
||
const hourlyMap = new Map<number, number>();
|
||
|
||
// 优化:批量处理数据,减少重复验证
|
||
result.data.forEach((sample: any) => {
|
||
if (sample?.hour >= 0 && sample?.hour < 24 && sample?.value !== undefined) {
|
||
hourlyMap.set(sample.hour, Math.round(sample.value));
|
||
}
|
||
});
|
||
|
||
// 生成最终数组
|
||
const hourlyData: HourlyStepData[] = [];
|
||
for (let i = 0; i < 24; i++) {
|
||
hourlyData.push({
|
||
hour: i,
|
||
steps: hourlyMap.get(i) || 0
|
||
});
|
||
}
|
||
|
||
return hourlyData;
|
||
} else {
|
||
logWarning('每小时步数', '为空或格式错误');
|
||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 }));
|
||
}
|
||
} catch (error) {
|
||
logError('每小时步数样本', error);
|
||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 }));
|
||
}
|
||
}
|
||
|
||
// 获取每小时活动热量数据
|
||
async function fetchHourlyActiveCalories(date: Date): Promise<HourlyActivityData[]> {
|
||
try {
|
||
const options = createDateRange(date);
|
||
const result = await HealthKitManager.getHourlyActiveEnergyBurned(options);
|
||
|
||
if (result && result.data && Array.isArray(result.data)) {
|
||
logSuccess('每小时活动热量', result);
|
||
|
||
// 初始化24小时数据
|
||
const hourlyData: HourlyActivityData[] = Array.from({ length: 24 }, (_, i) => ({
|
||
hour: i,
|
||
calories: 0
|
||
}));
|
||
|
||
// 将API返回的数据映射到对应的小时
|
||
result.data.forEach((sample: any) => {
|
||
if (sample && sample.hour !== undefined && sample.value !== undefined) {
|
||
const hour = sample.hour;
|
||
if (hour >= 0 && hour < 24) {
|
||
hourlyData[hour].calories = Math.round(sample.value);
|
||
}
|
||
}
|
||
});
|
||
|
||
return hourlyData;
|
||
} else {
|
||
logWarning('每小时活动热量', '为空或格式错误');
|
||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, calories: 0 }));
|
||
}
|
||
} catch (error) {
|
||
logError('每小时活动热量', error);
|
||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, calories: 0 }));
|
||
}
|
||
}
|
||
|
||
// 获取每小时锻炼分钟数据
|
||
async function fetchHourlyExerciseMinutes(date: Date): Promise<HourlyExerciseData[]> {
|
||
try {
|
||
const options = createDateRange(date);
|
||
const result = await HealthKitManager.getHourlyExerciseTime(options);
|
||
|
||
if (result && result.data && Array.isArray(result.data)) {
|
||
logSuccess('每小时锻炼分钟', result);
|
||
|
||
// 初始化24小时数据
|
||
const hourlyData: HourlyExerciseData[] = Array.from({ length: 24 }, (_, i) => ({
|
||
hour: i,
|
||
minutes: 0
|
||
}));
|
||
|
||
// 将API返回的数据映射到对应的小时
|
||
result.data.forEach((sample: any) => {
|
||
if (sample && sample.hour !== undefined && sample.value !== undefined) {
|
||
const hour = sample.hour;
|
||
if (hour >= 0 && hour < 24) {
|
||
hourlyData[hour].minutes = Math.round(sample.value);
|
||
}
|
||
}
|
||
});
|
||
|
||
return hourlyData;
|
||
} else {
|
||
logWarning('每小时锻炼分钟', '为空或格式错误');
|
||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 }));
|
||
}
|
||
} catch (error) {
|
||
logError('每小时锻炼分钟', error);
|
||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 }));
|
||
}
|
||
}
|
||
|
||
// 获取每小时站立小时数据
|
||
async function fetchHourlyStandHours(date: Date): Promise<number[]> {
|
||
try {
|
||
const options = createDateRange(date);
|
||
const result = await HealthKitManager.getHourlyStandHours(options);
|
||
|
||
if (result && result.data && Array.isArray(result.data)) {
|
||
logSuccess('每小时站立数据', result);
|
||
|
||
// 初始化24小时数据
|
||
const hourlyData: number[] = Array.from({ length: 24 }, () => 0);
|
||
|
||
// 将API返回的数据映射到对应的小时
|
||
result.data.forEach((sample: any) => {
|
||
if (sample && sample.hour !== undefined && sample.value !== undefined) {
|
||
const hour = sample.hour;
|
||
if (hour >= 0 && hour < 24) {
|
||
hourlyData[hour] = sample.value;
|
||
}
|
||
}
|
||
});
|
||
|
||
return hourlyData;
|
||
} else {
|
||
logWarning('每小时站立数据', '为空或格式错误');
|
||
return Array.from({ length: 24 }, () => 0);
|
||
}
|
||
} catch (error) {
|
||
logError('每小时站立数据', error);
|
||
return Array.from({ length: 24 }, () => 0);
|
||
}
|
||
}
|
||
|
||
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
|
||
try {
|
||
const result = await HealthKitManager.getActiveEnergyBurned(options);
|
||
|
||
if (result && result.totalValue !== undefined) {
|
||
logSuccess('消耗卡路里', result);
|
||
return result.totalValue;
|
||
} else {
|
||
logWarning('卡路里', '为空或格式错误');
|
||
return 0;
|
||
}
|
||
} catch (error) {
|
||
logError('消耗卡路里', error);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
export async function fetchBasalEnergyBurned(options: HealthDataOptions): Promise<number> {
|
||
try {
|
||
const result = await HealthKitManager.getBasalEnergyBurned(options);
|
||
|
||
if (result && result.totalValue !== undefined) {
|
||
logSuccess('基础代谢', result);
|
||
return result.totalValue;
|
||
} else {
|
||
logWarning('基础代谢', '为空或格式错误');
|
||
return 0;
|
||
}
|
||
} catch (error) {
|
||
logError('基础代谢', error);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
async function fetchHeartRateVariability(options: HealthDataOptions): Promise<HRVData | null> {
|
||
try {
|
||
console.log('=== 开始获取HRV数据 ===');
|
||
console.log('查询选项:', options);
|
||
|
||
const result = await HealthKitManager.getHeartRateVariabilitySamples(options);
|
||
console.log('HRV API调用结果:', result);
|
||
|
||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||
let selectedSample: any = null;
|
||
let bestQuality = -1;
|
||
|
||
console.log(`获取到 ${result.data.length} 个HRV样本`);
|
||
|
||
// 首先尝试使用最佳质量值
|
||
if (result.bestQualityValue && typeof result.bestQualityValue === 'number') {
|
||
const qualityValue = validateHRVValue(result.bestQualityValue);
|
||
if (qualityValue !== null) {
|
||
// 找到质量分数最高的样本
|
||
for (const sample of result.data) {
|
||
const sampleQuality = sample.qualityScore || 0;
|
||
const sampleValue = validateHRVValue(sample.value);
|
||
|
||
if (sampleValue !== null && sampleQuality > bestQuality) {
|
||
bestQuality = sampleQuality;
|
||
selectedSample = sample;
|
||
}
|
||
}
|
||
|
||
if (selectedSample) {
|
||
logSuccess('HRV数据(最佳质量)', {
|
||
value: qualityValue,
|
||
qualityScore: bestQuality,
|
||
totalSamples: result.data.length,
|
||
recordedAt: selectedSample.endDate
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有找到最佳质量样本,或者最佳质量值无效,重新评估所有样本
|
||
if (!selectedSample) {
|
||
console.log('重新评估所有样本以找到最佳数据...');
|
||
|
||
// 按质量分数、手动测量标志和时间排序
|
||
const sortedSamples = result.data.sort((a: any, b: any) => {
|
||
const qualityA = a.qualityScore || 0;
|
||
const qualityB = b.qualityScore || 0;
|
||
const isManualA = a.isManualMeasurement || false;
|
||
const isManualB = b.isManualMeasurement || false;
|
||
|
||
// 手动测量优先
|
||
if (isManualA && !isManualB) return -1;
|
||
if (!isManualA && isManualB) return 1;
|
||
|
||
// 质量分数优先
|
||
if (qualityA !== qualityB) return qualityB - qualityA;
|
||
|
||
// 时间优先(最新的优先)
|
||
const dateA = new Date(a.endDate || a.startDate).getTime();
|
||
const dateB = new Date(b.endDate || b.startDate).getTime();
|
||
return dateB - dateA;
|
||
});
|
||
|
||
// 选择第一个有效样本
|
||
for (const sample of sortedSamples) {
|
||
const sampleValue = validateHRVValue(sample.value);
|
||
if (sampleValue !== null) {
|
||
selectedSample = sample;
|
||
bestQuality = sample.qualityScore || 0;
|
||
console.log('选择最佳HRV样本:', {
|
||
value: sampleValue,
|
||
qualityScore: bestQuality,
|
||
isManual: sample.isManualMeasurement,
|
||
source: sample.source?.name,
|
||
recordedAt: sample.endDate
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 构建完整的HRV数据对象
|
||
if (selectedSample) {
|
||
const validatedValue = validateHRVValue(selectedSample.value);
|
||
if (validatedValue !== null) {
|
||
const hrvData: HRVData = {
|
||
value: validatedValue,
|
||
recordedAt: selectedSample.startDate,
|
||
endDate: selectedSample.endDate,
|
||
source: {
|
||
name: selectedSample.source?.name || 'Unknown',
|
||
bundleIdentifier: selectedSample.source?.bundleIdentifier || ''
|
||
},
|
||
isManualMeasurement: selectedSample.isManualMeasurement || false,
|
||
qualityScore: selectedSample.qualityScore,
|
||
sampleId: selectedSample.id
|
||
};
|
||
|
||
logSuccess('HRV完整数据', hrvData);
|
||
return hrvData;
|
||
}
|
||
}
|
||
}
|
||
|
||
logWarning('HRV', '为空或格式错误');
|
||
console.warn('HRV数据为空或无效,原始响应:', result);
|
||
return null;
|
||
} catch (error) {
|
||
logError('HRV数据', error);
|
||
console.error('HRV获取错误详情:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function fetchActivitySummary(options: HealthDataOptions): Promise<HealthActivitySummary | null> {
|
||
try {
|
||
const result = await HealthKitManager.getActivitySummary(options);
|
||
|
||
if (result && Array.isArray(result) && result.length > 0) {
|
||
logSuccess('ActivitySummary', result[0]);
|
||
return result[0];
|
||
} else {
|
||
logWarning('ActivitySummary', '为空');
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
logError('ActivitySummary', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function fetchOxygenSaturation(options: HealthDataOptions): Promise<number | null> {
|
||
try {
|
||
const result = await HealthKitManager.getOxygenSaturationSamples(options);
|
||
|
||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||
logSuccess('血氧饱和度', result);
|
||
const latestOxygen = result.data[result.data.length - 1];
|
||
return validateOxygenSaturation(latestOxygen?.value);
|
||
} else {
|
||
logWarning('血氧饱和度', '为空或格式错误');
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
logError('血氧饱和度', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function fetchHeartRateSamplesForRange(
|
||
startDate: Date,
|
||
endDate: Date,
|
||
limit: number = 2000
|
||
): Promise<HeartRateSample[]> {
|
||
try {
|
||
const options = {
|
||
startDate: dayjs(startDate).toISOString(),
|
||
endDate: dayjs(endDate).toISOString(),
|
||
limit,
|
||
};
|
||
|
||
const result = await HealthKitManager.getHeartRateSamples(options);
|
||
|
||
if (result && Array.isArray(result.data)) {
|
||
const samples: HeartRateSample[] = result.data
|
||
.filter((sample: any) => sample && typeof sample.value === 'number' && !Number.isNaN(sample.value))
|
||
.map((sample: any) => ({
|
||
id: sample.id,
|
||
startDate: sample.startDate,
|
||
endDate: sample.endDate,
|
||
value: Number(sample.value),
|
||
source: sample.source,
|
||
metadata: sample.metadata,
|
||
}));
|
||
|
||
logSuccess('锻炼心率采样', { count: samples.length, startDate: options.startDate, endDate: options.endDate });
|
||
return samples;
|
||
}
|
||
|
||
logWarning('锻炼心率采样', '为空或格式错误');
|
||
return [];
|
||
} catch (error) {
|
||
logError('锻炼心率采样', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function fetchHeartRate(options: HealthDataOptions): Promise<number | null> {
|
||
try {
|
||
const result = await HealthKitManager.getHeartRateSamples(options);
|
||
|
||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||
logSuccess('心率', result);
|
||
const latestHeartRate = result.data[result.data.length - 1];
|
||
return validateHeartRate(latestHeartRate?.value);
|
||
} else {
|
||
logWarning('心率', '为空或格式错误');
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
logError('心率', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 获取指定时间范围内的最大心率
|
||
export async function fetchMaximumHeartRate(_options: HealthDataOptions): Promise<number | null> {
|
||
try {
|
||
// 暂未实现,返回null
|
||
console.log('最大心率获取暂未实现');
|
||
return null;
|
||
|
||
// const result = await HealthKitManager.getHeartRateSamples(options);
|
||
|
||
// if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||
// // 从所有心率样本中找出最大值
|
||
// let maxHeartRate = 0;
|
||
// let validSamplesCount = 0;
|
||
|
||
// result.data.forEach((sample: any) => {
|
||
// if (sample && sample.value !== undefined) {
|
||
// const heartRate = validateHeartRate(sample.value);
|
||
// if (heartRate !== null) {
|
||
// maxHeartRate = Math.max(maxHeartRate, heartRate);
|
||
// validSamplesCount++;
|
||
// }
|
||
// }
|
||
// });
|
||
|
||
// if (validSamplesCount > 0 && maxHeartRate > 0) {
|
||
// logSuccess('最大心率', { maxHeartRate, validSamplesCount });
|
||
// return maxHeartRate;
|
||
// } else {
|
||
// logWarning('最大心率', '没有找到有效的样本数据');
|
||
// return null;
|
||
// }
|
||
// } else {
|
||
// logWarning('最大心率', '为空或格式错误');
|
||
// return null;
|
||
// }
|
||
} catch (error) {
|
||
logError('最大心率', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 默认健康数据
|
||
function getDefaultHealthData(): TodayHealthData {
|
||
return {
|
||
activeEnergyBurned: 0,
|
||
activeCalories: 0,
|
||
activeCaloriesGoal: 350,
|
||
exerciseMinutes: 0,
|
||
exerciseMinutesGoal: 30,
|
||
standHours: 0,
|
||
standHoursGoal: 12,
|
||
heartRate: null,
|
||
};
|
||
}
|
||
|
||
export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthData> {
|
||
try {
|
||
console.log('开始获取指定日期健康数据...', date);
|
||
|
||
const options = createDateRange(date);
|
||
console.log('查询选项:', options);
|
||
|
||
// 并行获取所有健康数据
|
||
const [
|
||
activeEnergyBurned,
|
||
activitySummary,
|
||
heartRate
|
||
] = await Promise.all([
|
||
fetchActiveEnergyBurned(options),
|
||
fetchActivitySummary(options),
|
||
fetchHeartRate(options)
|
||
]);
|
||
|
||
return {
|
||
activeEnergyBurned,
|
||
activeCalories: Math.round(activitySummary?.activeEnergyBurned || 0),
|
||
activeCaloriesGoal: Math.round(activitySummary?.activeEnergyBurnedGoal || 350),
|
||
exerciseMinutes: Math.round(activitySummary?.appleExerciseTime || 0),
|
||
exerciseMinutesGoal: Math.round(activitySummary?.appleExerciseTimeGoal || 30),
|
||
standHours: Math.round(activitySummary?.appleStandHours || 0),
|
||
standHoursGoal: Math.round(activitySummary?.appleStandHoursGoal || 12),
|
||
heartRate
|
||
};
|
||
} catch (error) {
|
||
console.error('获取指定日期健康数据失败:', error);
|
||
return getDefaultHealthData();
|
||
}
|
||
}
|
||
|
||
export async function fetchTodayHealthData(): Promise<TodayHealthData> {
|
||
return fetchHealthDataForDate(dayjs().toDate());
|
||
}
|
||
|
||
export async function fetchHRVForDate(date: Date): Promise<HRVData | null> {
|
||
console.log('开始获取指定日期HRV数据...', date);
|
||
|
||
// 首先尝试获取指定日期的HRV数据
|
||
const options = createDateRange(date);
|
||
const hrvData = await fetchHeartRateVariability(options);
|
||
|
||
// 如果当天没有数据,尝试获取最近7天内的最新数据
|
||
if (!hrvData) {
|
||
console.log('指定日期无HRV数据,尝试获取最近7天内的数据...');
|
||
|
||
const endDate = new Date(date);
|
||
const startDate = new Date(date);
|
||
startDate.setDate(startDate.getDate() - 7); // 往前推7天
|
||
|
||
const recentOptions = {
|
||
startDate: startDate.toISOString(),
|
||
endDate: endDate.toISOString()
|
||
};
|
||
|
||
const recentHrvData = await fetchHeartRateVariability(recentOptions);
|
||
|
||
if (recentHrvData) {
|
||
console.log('获取到最近7天内的HRV数据:', recentHrvData);
|
||
return recentHrvData;
|
||
} else {
|
||
console.log('最近7天内也无HRV数据');
|
||
}
|
||
}
|
||
|
||
return hrvData;
|
||
}
|
||
|
||
export async function fetchTodayHRV(): Promise<HRVData | null> {
|
||
return fetchHRVForDate(dayjs().toDate());
|
||
}
|
||
|
||
// 获取最近几小时内的实时HRV数据
|
||
export async function fetchRecentHRV(hoursBack: number = 2): Promise<HRVData | null> {
|
||
console.log(`开始获取最近${hoursBack}小时内的HRV数据...`);
|
||
|
||
const now = new Date();
|
||
const options = {
|
||
startDate: dayjs(now).subtract(hoursBack, 'hour').toDate().toISOString(),
|
||
endDate: now.toISOString()
|
||
};
|
||
|
||
return fetchHeartRateVariability(options);
|
||
}
|
||
|
||
// 测试HRV数据获取功能
|
||
export async function testHRVDataFetch(date: Date = dayjs().toDate()): Promise<void> {
|
||
console.log('=== 开始测试HRV数据获取 ===');
|
||
|
||
try {
|
||
// 首先确保权限
|
||
const hasPermission = await ensureHealthPermissions();
|
||
if (!hasPermission) {
|
||
console.error('没有健康数据权限,无法测试HRV');
|
||
return;
|
||
}
|
||
|
||
console.log('权限检查通过,开始获取HRV数据...');
|
||
|
||
// 测试不同时间范围的HRV数据
|
||
const options = createDateRange(date);
|
||
|
||
// 获取今日HRV(带详细分析)
|
||
console.log('--- 测试今日HRV ---');
|
||
const result = await HealthKitManager.getHeartRateVariabilitySamples(options);
|
||
console.log('原始HRV API响应:', result);
|
||
|
||
if (result && result.data && Array.isArray(result.data)) {
|
||
console.log(`获取到 ${result.data.length} 个HRV样本`);
|
||
|
||
// 分析数据质量
|
||
result.data.forEach((sample: any, index: number) => {
|
||
console.log(`样本 ${index + 1}:`, {
|
||
value: sample.value,
|
||
source: sample.source?.name,
|
||
bundleId: sample.source?.bundleIdentifier,
|
||
isManual: sample.isManualMeasurement,
|
||
qualityScore: sample.qualityScore,
|
||
startDate: sample.startDate,
|
||
endDate: sample.endDate
|
||
});
|
||
});
|
||
|
||
if (result.bestQualityValue !== undefined) {
|
||
console.log('最佳质量HRV值:', result.bestQualityValue);
|
||
}
|
||
}
|
||
|
||
// 使用优化后的方法获取HRV
|
||
const todayHRV = await fetchHeartRateVariability(options);
|
||
console.log('最终HRV结果:', todayHRV);
|
||
|
||
// 获取最近2小时HRV
|
||
console.log('--- 测试最近2小时HRV ---');
|
||
const recentHRV = await fetchRecentHRV(2);
|
||
console.log('最近2小时HRV结果:', recentHRV);
|
||
|
||
// 获取指定日期HRV
|
||
console.log('--- 测试指定日期HRV ---');
|
||
const dateHRV = await fetchHRVForDate(date);
|
||
console.log('指定日期HRV结果:', dateHRV);
|
||
|
||
// 提供数据解释
|
||
if (todayHRV) {
|
||
console.log('--- HRV数据解读 ---');
|
||
console.log(`HRV值: ${todayHRV.value}ms`);
|
||
console.log(`记录时间: ${todayHRV.recordedAt}`);
|
||
console.log(`数据来源: ${todayHRV.source.name}`);
|
||
console.log(`手动测量: ${todayHRV.isManualMeasurement ? '是' : '否'}`);
|
||
|
||
if (todayHRV.value >= 18 && todayHRV.value <= 76) {
|
||
console.log('✅ HRV值在正常范围内 (18-76ms)');
|
||
} else if (todayHRV.value < 18) {
|
||
console.log('⚠️ HRV值偏低,可能表示压力或疲劳状态');
|
||
} else if (todayHRV.value > 76) {
|
||
console.log('📈 HRV值较高,通常表示良好的恢复状态');
|
||
}
|
||
}
|
||
|
||
console.log('=== HRV数据测试完成 ===');
|
||
} catch (error) {
|
||
console.error('HRV测试过程中出现错误:', error);
|
||
}
|
||
}
|
||
|
||
// 更新healthkit中的体重 (暂未实现)
|
||
export async function updateWeight(_weight: number) {
|
||
try {
|
||
// Note: Weight saving would need to be implemented in native module
|
||
console.log('体重保存到HealthKit暂未实现');
|
||
return true; // Return true for now to not break existing functionality
|
||
} catch (error) {
|
||
console.error('更新体重失败:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export async function testOxygenSaturationData(_date: Date = dayjs().toDate()): Promise<void> {
|
||
console.log('=== 开始测试血氧饱和度数据获取 ===');
|
||
|
||
// const options = createDateRange(date);
|
||
|
||
try {
|
||
// const result = await HealthKitManager.getOxygenSaturationSamples(options);
|
||
|
||
// console.log('原始血氧饱和度数据:', result);
|
||
|
||
// if (!result || !result.data || !Array.isArray(result.data) || result.data.length === 0) {
|
||
// console.warn('血氧饱和度数据为空');
|
||
// return;
|
||
// }
|
||
|
||
// // 分析所有数据样本
|
||
// result.data.forEach((sample: any, index: number) => {
|
||
// console.log(`样本 ${index + 1}:`, {
|
||
// value: sample.value,
|
||
// valueType: typeof sample.value,
|
||
// startDate: sample.startDate,
|
||
// endDate: sample.endDate
|
||
// });
|
||
// });
|
||
|
||
// // 获取最新的血氧饱和度值并验证
|
||
// const latestOxygen = result.data[result.data.length - 1];
|
||
// if (latestOxygen?.value !== undefined && latestOxygen?.value !== null) {
|
||
// const processedValue = validateOxygenSaturation(latestOxygen.value);
|
||
|
||
// console.log('处理前的值:', latestOxygen.value);
|
||
// console.log('最终处理后的值:', processedValue);
|
||
// console.log('数据有效性检查:', processedValue !== null ? '有效' : '无效');
|
||
// }
|
||
|
||
// console.log('=== 血氧饱和度数据测试完成 ===');
|
||
} catch (error) {
|
||
console.error('获取血氧饱和度失败:', error);
|
||
}
|
||
}
|
||
|
||
// 添加饮水记录到 HealthKit
|
||
export async function saveWaterIntakeToHealthKit(amount: number, recordedAt?: string): Promise<boolean> {
|
||
try {
|
||
console.log('开始保存饮水记录到HealthKit...', { amount, recordedAt });
|
||
|
||
const options = {
|
||
amount: amount,
|
||
recordedAt: recordedAt || new Date().toISOString()
|
||
};
|
||
|
||
const result = await HealthKitManager.saveWaterIntakeToHealthKit(options);
|
||
|
||
if (result && result.success) {
|
||
console.log('饮水记录保存成功:', result);
|
||
return true;
|
||
} else {
|
||
console.error('饮水记录保存失败:', result);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('添加饮水记录到 HealthKit 失败:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 获取 HealthKit 中的饮水记录
|
||
export async function getWaterIntakeFromHealthKit(options: HealthDataOptions): Promise<any[]> {
|
||
try {
|
||
console.log('开始从HealthKit获取饮水记录...', options);
|
||
|
||
const result = await HealthKitManager.getWaterIntakeFromHealthKit(options);
|
||
|
||
if (result && result.data && Array.isArray(result.data)) {
|
||
console.log('成功获取饮水记录:', result);
|
||
return result.data;
|
||
} else {
|
||
console.log('饮水记录为空或格式错误:', result);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
console.error('获取 HealthKit 饮水记录失败:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 删除 HealthKit 中的饮水记录
|
||
// 注意: react-native-health 库可能不支持直接删除特定记录,这个功能可能需要手动实现或使用其他方法
|
||
export async function deleteWaterIntakeFromHealthKit(recordId: string, recordedAt: string): Promise<boolean> {
|
||
// HealthKit 通常不支持直接删除单条记录
|
||
// 这是一个占位函数,实际实现可能需要更复杂的逻辑
|
||
console.log('注意: HealthKit 通常不支持直接删除单条饮水记录');
|
||
console.log('记录信息:', { recordId, recordedAt });
|
||
|
||
// 返回 true 表示"成功"(但实际上可能没有真正删除)
|
||
return Promise.resolve(true);
|
||
}
|
||
|
||
// 获取当前小时的站立状态
|
||
export async function getCurrentHourStandStatus(): Promise<{ hasStood: boolean; standHours: number; standHoursGoal: number }> {
|
||
try {
|
||
const currentHour = new Date().getHours();
|
||
console.log(`检查当前小时 ${currentHour} 的站立状态...`);
|
||
|
||
// 获取今日健康数据
|
||
const todayHealthData = await fetchTodayHealthData();
|
||
|
||
return {
|
||
hasStood: todayHealthData.standHours > currentHour - 1, // 如果站立小时数大于当前小时-1,说明当前小时已站立
|
||
standHours: todayHealthData.standHours,
|
||
standHoursGoal: todayHealthData.standHoursGoal
|
||
};
|
||
} catch (error) {
|
||
console.error('获取当前小时站立状态失败:', error);
|
||
return {
|
||
hasStood: true, // 默认认为已站立,避免过度提醒
|
||
standHours: 0,
|
||
standHoursGoal: 12
|
||
};
|
||
}
|
||
}
|
||
|
||
// === 专门为健身圆环详情页提供的独立函数 ===
|
||
|
||
// 精简的活动圆环数据类型,只包含必要字段
|
||
export type ActivityRingsData = {
|
||
// 活动圆环数据(来自 getActivitySummary)
|
||
activeEnergyBurned: number; // activeEnergyBurned
|
||
activeEnergyBurnedGoal: number; // activeEnergyBurnedGoal
|
||
appleExerciseTime: number; // appleExerciseTime (分钟)
|
||
appleExerciseTimeGoal: number; // appleExerciseTimeGoal
|
||
appleStandHours: number; // appleStandHours
|
||
appleStandHoursGoal: number; // appleStandHoursGoal
|
||
};
|
||
|
||
// 导出每小时活动热量数据获取函数
|
||
export async function fetchHourlyActiveCaloriesForDate(date: Date): Promise<HourlyActivityData[]> {
|
||
return fetchHourlyActiveCalories(date);
|
||
}
|
||
|
||
// 导出每小时锻炼分钟数据获取函数
|
||
export async function fetchHourlyExerciseMinutesForDate(date: Date): Promise<HourlyExerciseData[]> {
|
||
return fetchHourlyExerciseMinutes(date);
|
||
}
|
||
|
||
// 导出每小时站立数据获取函数
|
||
export async function fetchHourlyStandHoursForDate(date: Date): Promise<HourlyStandData[]> {
|
||
const hourlyStandData = await fetchHourlyStandHours(date);
|
||
return hourlyStandData.map((hasStood, hour) => ({
|
||
hour,
|
||
hasStood
|
||
}));
|
||
}
|
||
|
||
// 专门为活动圆环详情页获取精简的数据
|
||
export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRingsData | null> {
|
||
try {
|
||
console.log('获取活动圆环数据...', date);
|
||
const options = createDateRange(date);
|
||
|
||
const activitySummary = await fetchActivitySummary(options);
|
||
|
||
if (!activitySummary) {
|
||
console.warn('ActivitySummary 数据为空');
|
||
return null;
|
||
}
|
||
|
||
// 直接使用 getActivitySummary 返回的字段名,与文档保持一致
|
||
return {
|
||
activeEnergyBurned: Math.round(activitySummary.activeEnergyBurned || 0),
|
||
activeEnergyBurnedGoal: Math.round(activitySummary.activeEnergyBurnedGoal || 350),
|
||
appleExerciseTime: Math.round(activitySummary.appleExerciseTime || 0),
|
||
appleExerciseTimeGoal: Math.round(activitySummary.appleExerciseTimeGoal || 30),
|
||
appleStandHours: Math.round(activitySummary.appleStandHours || 0),
|
||
appleStandHoursGoal: Math.round(activitySummary.appleStandHoursGoal || 12),
|
||
};
|
||
} catch (error) {
|
||
console.error('获取活动圆环数据失败:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// === 权限管理工具函数 ===
|
||
|
||
// 初始化健康权限管理(应在应用启动时调用)
|
||
export function initializeHealthPermissions() {
|
||
console.log('初始化HealthKit权限管理系统...');
|
||
|
||
// 延迟检查权限状态,避免应用启动时的性能影响
|
||
setTimeout(() => {
|
||
healthPermissionManager.checkPermissionStatus(true);
|
||
}, 1000);
|
||
}
|
||
|
||
// 监听权限状态变化(用于组件外部使用)
|
||
export function addHealthPermissionListener(
|
||
event: 'permissionStatusChanged' | 'permissionGranted',
|
||
listener: (...args: any[]) => void
|
||
) {
|
||
healthPermissionManager.on(event, listener);
|
||
}
|
||
|
||
// 移除权限状态监听器
|
||
export function removeHealthPermissionListener(
|
||
event: 'permissionStatusChanged' | 'permissionGranted',
|
||
listener: (...args: any[]) => void
|
||
) {
|
||
healthPermissionManager.off(event, listener);
|
||
}
|
||
|
||
// 清理权限管理资源(应在应用退出时调用)
|
||
export function cleanupHealthPermissions() {
|
||
console.log('清理HealthKit权限管理资源...');
|
||
healthPermissionManager.destroy();
|
||
}
|
||
|
||
// 获取权限状态的可读文本
|
||
export function getPermissionStatusText(status: HealthPermissionStatus): string {
|
||
switch (status) {
|
||
case HealthPermissionStatus.Authorized:
|
||
return '已授权';
|
||
case HealthPermissionStatus.Denied:
|
||
return '已拒绝';
|
||
case HealthPermissionStatus.NotDetermined:
|
||
return '未确定';
|
||
case HealthPermissionStatus.Unknown:
|
||
default:
|
||
return '未知';
|
||
}
|
||
}
|
||
|
||
// 检查是否需要显示权限请求UI
|
||
export function shouldShowPermissionRequest(): boolean {
|
||
const status = healthPermissionManager.getPermissionStatus();
|
||
return status === HealthPermissionStatus.NotDetermined ||
|
||
status === HealthPermissionStatus.Unknown;
|
||
}
|
||
|
||
// 检查是否权限被用户拒绝
|
||
export function isPermissionDenied(): boolean {
|
||
const status = healthPermissionManager.getPermissionStatus();
|
||
return status === HealthPermissionStatus.Denied;
|
||
}
|
||
|
||
// HRV数据结构
|
||
export interface HRVData {
|
||
value: number;
|
||
recordedAt: string; // ISO string format
|
||
endDate: string; // ISO string format
|
||
source: {
|
||
name: string;
|
||
bundleIdentifier: string;
|
||
};
|
||
isManualMeasurement: boolean;
|
||
qualityScore?: number;
|
||
sampleId?: string;
|
||
}
|
||
|
||
// HRV数据质量分析和解读
|
||
export interface HRVAnalysis {
|
||
value: number;
|
||
quality: 'excellent' | 'good' | 'fair' | 'poor';
|
||
interpretation: string;
|
||
recommendations: string[];
|
||
dataSource: string;
|
||
isManualMeasurement: boolean;
|
||
recordedAt: string;
|
||
}
|
||
|
||
export function analyzeHRVData(hrvData: HRVData): HRVAnalysis {
|
||
const { value: hrvValue, source, isManualMeasurement, recordedAt } = hrvData;
|
||
const sourceName = source.name;
|
||
|
||
let quality: HRVAnalysis['quality'];
|
||
let interpretation: string;
|
||
let recommendations: string[] = [];
|
||
|
||
// 质量评估基于数值范围和数据来源
|
||
if (hrvValue >= 18 && hrvValue <= 76) {
|
||
if (isManualMeasurement) {
|
||
quality = 'excellent';
|
||
interpretation = 'HRV值在正常范围内,且来自高质量测量';
|
||
} else {
|
||
quality = 'good';
|
||
interpretation = 'HRV值在正常范围内';
|
||
}
|
||
} else if (hrvValue >= 10 && hrvValue < 18) {
|
||
quality = 'fair';
|
||
interpretation = 'HRV值偏低,可能表示压力、疲劳或恢复不足';
|
||
recommendations.push('考虑增加休息和恢复时间');
|
||
recommendations.push('评估近期的压力水平和睡眠质量');
|
||
} else if (hrvValue > 76 && hrvValue <= 100) {
|
||
quality = isManualMeasurement ? 'excellent' : 'good';
|
||
interpretation = 'HRV值较高,通常表示良好的心血管健康和恢复状态';
|
||
recommendations.push('保持当前的生活方式和训练强度');
|
||
} else if (hrvValue < 10) {
|
||
quality = 'poor';
|
||
interpretation = 'HRV值异常低,建议关注身体状态或数据准确性';
|
||
recommendations.push('建议使用手动测量(如呼吸应用)获得更准确的数据');
|
||
recommendations.push('如持续偏低,建议咨询医疗专业人士');
|
||
} else if (hrvValue > 100) {
|
||
quality = 'fair';
|
||
interpretation = 'HRV值异常高,可能需要验证数据准确性';
|
||
recommendations.push('建议重复测量确认数据准确性');
|
||
} else {
|
||
quality = 'poor';
|
||
interpretation = 'HRV数据超出预期范围';
|
||
recommendations.push('建议使用标准化的测量方法');
|
||
}
|
||
|
||
// 根据数据来源添加建议
|
||
if (!isManualMeasurement) {
|
||
recommendations.push('推荐使用呼吸应用进行手动HRV测量以获得更准确的数据');
|
||
}
|
||
|
||
return {
|
||
value: hrvValue,
|
||
quality,
|
||
interpretation,
|
||
recommendations,
|
||
dataSource: sourceName,
|
||
isManualMeasurement,
|
||
recordedAt
|
||
};
|
||
}
|
||
|
||
// 获取HRV数据并提供分析
|
||
export async function fetchHRVWithAnalysis(date: Date): Promise<{ hrvData: HRVData | null; analysis: HRVAnalysis | null }> {
|
||
try {
|
||
const hrvData = await fetchHRVForDate(date);
|
||
|
||
if (hrvData) {
|
||
const analysis = analyzeHRVData(hrvData);
|
||
return { hrvData, analysis };
|
||
}
|
||
|
||
return { hrvData: null, analysis: null };
|
||
} catch (error) {
|
||
console.error('获取HRV分析数据失败:', error);
|
||
return { hrvData: null, analysis: null };
|
||
}
|
||
}
|
||
|
||
|
||
// 智能HRV数据获取 - 优先获取实时数据,如果没有则获取历史数据
|
||
export async function fetchSmartHRVData(date: Date): Promise<HRVData | null> {
|
||
console.log('开始智能HRV数据获取...', date);
|
||
|
||
try {
|
||
// 1. 首先尝试获取最近2小时的实时数据
|
||
console.log('1. 尝试获取最近2小时的实时HRV数据...');
|
||
const recentHRV = await fetchRecentHRV(2);
|
||
|
||
if (recentHRV) {
|
||
console.log('✅ 成功获取到实时HRV数据:', recentHRV);
|
||
|
||
// 检查数据是否足够新(1小时内)
|
||
const dataTime = new Date(recentHRV.recordedAt).getTime();
|
||
const now = Date.now();
|
||
const oneHour = 60 * 60 * 1000;
|
||
|
||
if (now - dataTime <= oneHour) {
|
||
console.log('✅ 实时数据足够新,直接使用');
|
||
return recentHRV;
|
||
} else {
|
||
console.log('⚠️ 实时数据较旧,继续寻找更好的数据');
|
||
}
|
||
}
|
||
|
||
// 2. 如果没有实时数据或数据太旧,尝试获取当天的数据
|
||
console.log('2. 尝试获取当天的HRV数据...');
|
||
const todayHRV = await fetchHRVForDate(date);
|
||
|
||
if (todayHRV) {
|
||
console.log('✅ 成功获取到当天HRV数据:', todayHRV);
|
||
return todayHRV;
|
||
}
|
||
|
||
// 3. 如果当天没有数据,尝试获取最近3天的数据
|
||
console.log('3. 尝试获取最近3天的HRV数据...');
|
||
const endDate = new Date(date);
|
||
const startDate = new Date(date);
|
||
startDate.setDate(startDate.getDate() - 3);
|
||
|
||
const recentOptions = {
|
||
startDate: startDate.toISOString(),
|
||
endDate: endDate.toISOString()
|
||
};
|
||
|
||
const recentData = await fetchHeartRateVariability(recentOptions);
|
||
|
||
if (recentData) {
|
||
console.log('✅ 成功获取到最近3天的HRV数据:', recentData);
|
||
return recentData;
|
||
}
|
||
|
||
// 4. 如果仍然没有数据,返回null
|
||
console.log('❌ 未找到任何HRV数据');
|
||
return null;
|
||
|
||
} catch (error) {
|
||
console.error('智能HRV数据获取失败:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// === 锻炼记录相关方法 ===
|
||
|
||
// 获取最近锻炼记录
|
||
export async function fetchRecentWorkouts(options?: Partial<WorkoutOptions>): Promise<WorkoutData[]> {
|
||
try {
|
||
console.log('开始获取最近锻炼记录...', options);
|
||
|
||
// 设置默认选项
|
||
const defaultOptions: WorkoutOptions = {
|
||
startDate: dayjs().subtract(30, 'day').startOf('day').toISOString(),
|
||
endDate: dayjs().endOf('day').toISOString(),
|
||
limit: 10
|
||
};
|
||
|
||
const finalOptions = { ...defaultOptions, ...options };
|
||
|
||
const result = await HealthKitManager.getRecentWorkouts(finalOptions);
|
||
|
||
if (result && result.data && Array.isArray(result.data)) {
|
||
logSuccess('锻炼记录', result);
|
||
|
||
// 验证和处理返回的数据
|
||
const validatedWorkouts: WorkoutData[] = result.data
|
||
.filter((workout: any) => {
|
||
// 基本数据验证
|
||
return workout &&
|
||
workout.id &&
|
||
workout.startDate &&
|
||
workout.endDate &&
|
||
workout.duration !== undefined;
|
||
})
|
||
.map((workout: any) => ({
|
||
id: workout.id,
|
||
startDate: workout.startDate,
|
||
endDate: workout.endDate,
|
||
duration: workout.duration,
|
||
workoutActivityType: workout.workoutActivityType || 0,
|
||
workoutActivityTypeString: workout.workoutActivityTypeString || 'unknown',
|
||
totalEnergyBurned: workout.totalEnergyBurned,
|
||
totalDistance: workout.totalDistance,
|
||
averageHeartRate: workout.averageHeartRate,
|
||
source: {
|
||
name: workout.source?.name || 'Unknown',
|
||
bundleIdentifier: workout.source?.bundleIdentifier || ''
|
||
},
|
||
metadata: workout.metadata || {}
|
||
}));
|
||
|
||
console.log(`成功获取 ${validatedWorkouts.length} 条锻炼记录`);
|
||
return validatedWorkouts;
|
||
} else {
|
||
logWarning('锻炼记录', '为空或格式错误');
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
logError('锻炼记录', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 获取指定日期范围内的锻炼记录
|
||
export async function fetchWorkoutsForDateRange(
|
||
startDate: Date,
|
||
endDate: Date,
|
||
limit: number = 10
|
||
): Promise<WorkoutData[]> {
|
||
const options: WorkoutOptions = {
|
||
startDate: dayjs(startDate).startOf('day').toISOString(),
|
||
endDate: dayjs(endDate).endOf('day').toISOString(),
|
||
limit
|
||
};
|
||
|
||
return fetchRecentWorkouts(options);
|
||
}
|
||
|
||
// 获取今日锻炼记录
|
||
export async function fetchTodayWorkouts(): Promise<WorkoutData[]> {
|
||
const today = dayjs();
|
||
return fetchWorkoutsForDateRange(today.toDate(), today.toDate(), 20);
|
||
}
|
||
|
||
// 获取本周锻炼记录
|
||
export async function fetchThisWeekWorkouts(): Promise<WorkoutData[]> {
|
||
const today = dayjs();
|
||
const startOfWeek = today.startOf('week');
|
||
return fetchWorkoutsForDateRange(startOfWeek.toDate(), today.toDate(), 50);
|
||
}
|
||
|
||
// 获取本月锻炼记录
|
||
export async function fetchThisMonthWorkouts(): Promise<WorkoutData[]> {
|
||
const today = dayjs();
|
||
const startOfMonth = today.startOf('month');
|
||
return fetchWorkoutsForDateRange(startOfMonth.toDate(), today.toDate(), 100);
|
||
}
|
||
|
||
// 根据锻炼类型筛选锻炼记录
|
||
export function filterWorkoutsByType(
|
||
workouts: WorkoutData[],
|
||
workoutType: WorkoutActivityType
|
||
): WorkoutData[] {
|
||
return workouts.filter(workout => workout.workoutActivityType === workoutType);
|
||
}
|
||
|
||
// 获取锻炼统计信息
|
||
export function getWorkoutStatistics(workouts: WorkoutData[]): {
|
||
totalWorkouts: number;
|
||
totalDuration: number; // 秒
|
||
totalEnergyBurned: number; // 千卡
|
||
totalDistance: number; // 米
|
||
averageDuration: number; // 秒
|
||
workoutTypes: Record<string, number>; // 各类型锻炼次数
|
||
} {
|
||
const stats = {
|
||
totalWorkouts: workouts.length,
|
||
totalDuration: 0,
|
||
totalEnergyBurned: 0,
|
||
totalDistance: 0,
|
||
averageDuration: 0,
|
||
workoutTypes: {} as Record<string, number>
|
||
};
|
||
|
||
workouts.forEach(workout => {
|
||
stats.totalDuration += workout.duration;
|
||
stats.totalEnergyBurned += workout.totalEnergyBurned || 0;
|
||
stats.totalDistance += workout.totalDistance || 0;
|
||
|
||
// 统计锻炼类型
|
||
const typeString = workout.workoutActivityTypeString;
|
||
stats.workoutTypes[typeString] = (stats.workoutTypes[typeString] || 0) + 1;
|
||
});
|
||
|
||
if (stats.totalWorkouts > 0) {
|
||
stats.averageDuration = Math.round(stats.totalDuration / stats.totalWorkouts);
|
||
}
|
||
|
||
return stats;
|
||
}
|
||
|
||
// 格式化锻炼持续时间
|
||
export function formatWorkoutDuration(durationInSeconds: number): string {
|
||
const hours = Math.floor(durationInSeconds / 3600);
|
||
const minutes = Math.floor((durationInSeconds % 3600) / 60);
|
||
const seconds = durationInSeconds % 60;
|
||
|
||
if (hours > 0) {
|
||
return `${hours}小时${minutes}分钟`;
|
||
} else if (minutes > 0) {
|
||
return `${minutes}分钟${seconds}秒`;
|
||
} else {
|
||
return `${seconds}秒`;
|
||
}
|
||
}
|
||
|
||
// 格式化锻炼距离
|
||
export function formatWorkoutDistance(distanceInMeters: number): string {
|
||
if (distanceInMeters >= 1000) {
|
||
return `${(distanceInMeters / 1000).toFixed(2)}公里`;
|
||
} else {
|
||
return `${Math.round(distanceInMeters)}米`;
|
||
}
|
||
}
|
||
|
||
const WORKOUT_TYPE_LABELS: Record<string, string> = {
|
||
running: '跑步',
|
||
walking: '步行',
|
||
cycling: '骑行',
|
||
swimming: '游泳',
|
||
yoga: '瑜伽',
|
||
functionalstrengthtraining: '功能性力量训练',
|
||
traditionalstrengthtraining: '传统力量训练',
|
||
crosstraining: '交叉训练',
|
||
mixedcardio: '混合有氧',
|
||
highintensityintervaltraining: '高强度间歇训练',
|
||
flexibility: '柔韧性训练',
|
||
cooldown: '放松运动',
|
||
pilates: '普拉提',
|
||
dance: '舞蹈',
|
||
danceinspiredtraining: '舞蹈训练',
|
||
cardiodance: '有氧舞蹈',
|
||
socialdance: '社交舞',
|
||
swimbikerun: '铁人三项',
|
||
transition: '项目转换',
|
||
underwaterdiving: '水下潜水',
|
||
pickleball: '匹克球',
|
||
americanfootball: '美式橄榄球',
|
||
badminton: '羽毛球',
|
||
baseball: '棒球',
|
||
basketball: '篮球',
|
||
tennis: '网球',
|
||
tabletennis: '乒乓球',
|
||
functionalStrengthTraining: '功能性力量训练',
|
||
other: '其他运动',
|
||
};
|
||
|
||
function humanizeWorkoutTypeKey(raw: string | undefined): string {
|
||
if (!raw) {
|
||
return '其他运动';
|
||
}
|
||
|
||
const cleaned = raw
|
||
.replace(/^HKWorkoutActivityType/i, '')
|
||
.replace(/[_\-]+/g, ' ')
|
||
.trim();
|
||
|
||
if (!cleaned) {
|
||
return '其他运动';
|
||
}
|
||
|
||
const withSpaces = cleaned.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
||
const words = withSpaces
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||
|
||
return words.join(' ');
|
||
}
|
||
|
||
// 获取锻炼类型的显示名称
|
||
export function getWorkoutTypeDisplayName(workoutType: WorkoutActivityType | string): string {
|
||
if (typeof workoutType === 'string') {
|
||
const normalized = workoutType.replace(/\s+/g, '').toLowerCase();
|
||
return WORKOUT_TYPE_LABELS[normalized] || humanizeWorkoutTypeKey(workoutType);
|
||
}
|
||
|
||
switch (workoutType) {
|
||
case WorkoutActivityType.Running:
|
||
return '跑步';
|
||
case WorkoutActivityType.Cycling:
|
||
return '骑行';
|
||
case WorkoutActivityType.Walking:
|
||
return '步行';
|
||
case WorkoutActivityType.Swimming:
|
||
return '游泳';
|
||
case WorkoutActivityType.Yoga:
|
||
return '瑜伽';
|
||
case WorkoutActivityType.FunctionalStrengthTraining:
|
||
return '功能性力量训练';
|
||
case WorkoutActivityType.TraditionalStrengthTraining:
|
||
return '传统力量训练';
|
||
case WorkoutActivityType.CrossTraining:
|
||
return '交叉训练';
|
||
case WorkoutActivityType.MixedCardio:
|
||
return '混合有氧';
|
||
case WorkoutActivityType.HighIntensityIntervalTraining:
|
||
return '高强度间歇训练';
|
||
case WorkoutActivityType.Flexibility:
|
||
return '柔韧性训练';
|
||
case WorkoutActivityType.Cooldown:
|
||
return '放松运动';
|
||
case WorkoutActivityType.Tennis:
|
||
return '网球';
|
||
case WorkoutActivityType.Other:
|
||
return '其他运动';
|
||
default:
|
||
return humanizeWorkoutTypeKey(WorkoutActivityType[workoutType]);
|
||
}
|
||
}
|
||
|
||
// 测试锻炼记录获取功能
|
||
export async function testWorkoutDataFetch(): Promise<void> {
|
||
console.log('=== 开始测试锻炼记录获取 ===');
|
||
|
||
try {
|
||
// 确保权限
|
||
const hasPermission = await ensureHealthPermissions();
|
||
if (!hasPermission) {
|
||
console.error('没有健康数据权限,无法测试锻炼记录');
|
||
return;
|
||
}
|
||
|
||
console.log('权限检查通过,开始获取锻炼记录...');
|
||
|
||
// 测试获取最近锻炼记录
|
||
console.log('--- 测试获取最近锻炼记录 ---');
|
||
const recentWorkouts = await fetchRecentWorkouts();
|
||
console.log(`获取到 ${recentWorkouts.length} 条最近锻炼记录`);
|
||
|
||
recentWorkouts.forEach((workout, index) => {
|
||
console.log(`锻炼 ${index + 1}:`, {
|
||
类型: getWorkoutTypeDisplayName(workout.workoutActivityTypeString),
|
||
持续时间: formatWorkoutDuration(workout.duration),
|
||
能量消耗: workout.totalEnergyBurned ? `${workout.totalEnergyBurned}千卡` : '无',
|
||
距离: workout.totalDistance ? formatWorkoutDistance(workout.totalDistance) : '无',
|
||
开始时间: workout.startDate,
|
||
数据来源: workout.source.name
|
||
});
|
||
});
|
||
|
||
// 测试统计功能
|
||
if (recentWorkouts.length > 0) {
|
||
console.log('--- 锻炼统计信息 ---');
|
||
const stats = getWorkoutStatistics(recentWorkouts);
|
||
console.log('统计结果:', {
|
||
总锻炼次数: stats.totalWorkouts,
|
||
总持续时间: formatWorkoutDuration(stats.totalDuration),
|
||
总能量消耗: `${stats.totalEnergyBurned}千卡`,
|
||
总距离: formatWorkoutDistance(stats.totalDistance),
|
||
平均持续时间: formatWorkoutDuration(stats.averageDuration),
|
||
锻炼类型分布: stats.workoutTypes
|
||
});
|
||
}
|
||
|
||
console.log('=== 锻炼记录测试完成 ===');
|
||
} catch (error) {
|
||
console.error('锻炼记录测试过程中出现错误:', error);
|
||
}
|
||
}
|
||
|
||
// 获取HRV数据并附带详细的状态信息
|
||
export async function fetchHRVWithStatus(date: Date): Promise<{
|
||
hrvData: HRVData | null;
|
||
status: 'realtime' | 'recent' | 'historical' | 'none';
|
||
message: string;
|
||
}> {
|
||
try {
|
||
const hrvData = await fetchSmartHRVData(date);
|
||
|
||
if (!hrvData) {
|
||
return {
|
||
hrvData: null,
|
||
status: 'none',
|
||
message: '未找到HRV数据'
|
||
};
|
||
}
|
||
|
||
const dataTime = new Date(hrvData.recordedAt).getTime();
|
||
const now = Date.now();
|
||
const oneHour = 60 * 60 * 1000;
|
||
const oneDay = 24 * 60 * 60 * 1000;
|
||
|
||
let status: 'realtime' | 'recent' | 'historical';
|
||
let message: string;
|
||
|
||
if (now - dataTime <= oneHour) {
|
||
status = 'realtime';
|
||
message = '实时HRV数据';
|
||
} else if (now - dataTime <= oneDay) {
|
||
status = 'recent';
|
||
message = '近期HRV数据';
|
||
} else {
|
||
status = 'historical';
|
||
message = '历史HRV数据';
|
||
}
|
||
|
||
return {
|
||
hrvData,
|
||
status,
|
||
message
|
||
};
|
||
|
||
} catch (error) {
|
||
console.error('获取HRV状态失败:', error);
|
||
return {
|
||
hrvData: null,
|
||
status: 'none',
|
||
message: '获取HRV数据失败'
|
||
};
|
||
}
|
||
}
|