feat(workout): 新增锻炼历史记录功能与健康数据集成
- 新增锻炼历史页面,展示最近一个月的锻炼记录详情 - 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据 - 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息 - 完善锻炼数据处理工具,包含统计分析和格式化功能 - 优化后台任务,随机选择挑战发送鼓励通知 - 版本升级至1.0.16
This commit is contained in:
420
utils/health.ts
420
utils/health.ts
@@ -5,8 +5,120 @@ 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 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;
|
||||
|
||||
@@ -1317,6 +1429,314 @@ export async function fetchSmartHRVData(date: Date): Promise<HRVData | 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;
|
||||
|
||||
Reference in New Issue
Block a user