diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 2151d7f..2cc7520 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -14,7 +14,7 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { syncHealthKitToServer } from '@/services/healthKitSync'; +import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync'; import { setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { updateUserProfile } from '@/store/userSlice'; @@ -64,7 +64,8 @@ export default function ExploreScreen() { const { t } = useTranslation(); const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; const userProfile = useAppSelector((s) => s.user.profile); - + const todayWaterStats = useAppSelector((s) => s.water.todayStats); + const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard(); const router = useRouter(); @@ -293,6 +294,7 @@ export default function ExploreScreen() { try { logger.info('开始同步 HealthKit 个人健康数据到服务端...'); + // 1. 同步个人资料 (身高、体重、出生日期) // 传入当前用户资料,用于 diff 比较 const success = await syncHealthKitToServer( async (data) => { @@ -302,20 +304,36 @@ export default function ExploreScreen() { ); if (success) { - logger.info('HealthKit 数据同步到服务端成功'); + logger.info('HealthKit 个人资料同步到服务端成功'); } else { - logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败'); + logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败'); } + + // 2. 同步每日健康数据报表 (活动、睡眠、心率等) + // 传入今日饮水量 + const waterIntake = todayWaterStats?.totalAmount; + logger.info('开始同步每日健康数据报表...', { waterIntake }); + + const reportSuccess = await syncDailyHealthReport(waterIntake); + + if (reportSuccess) { + logger.info('每日健康数据报表同步成功'); + } else { + logger.info('每日健康数据报表同步跳过(无变化)或失败'); + } + } catch (error) { logger.error('同步 HealthKit 数据到服务端失败:', error); } - }, [isLoggedIn, dispatch, userProfile]); + }, [isLoggedIn, dispatch, userProfile, todayWaterStats]); // 初始加载时执行数据加载和同步 useEffect(() => { loadAllData(currentSelectedDate); // 延迟1秒后执行同步,避免影响初始加载性能 + // 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined + // 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用 const syncTimer = setTimeout(() => { syncHealthDataToServer(); }, 1000); diff --git a/hooks/useWaterData.ts b/hooks/useWaterData.ts index 8198d04..07d4707 100644 --- a/hooks/useWaterData.ts +++ b/hooks/useWaterData.ts @@ -1,6 +1,8 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { ChallengeType } from '@/services/challengesApi'; +import { WaterRecordSource } from '@/services/waterRecords'; import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice'; +import { createWaterRecordAction } from '@/store/waterSlice'; import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health'; import { Toast } from '@/utils/toast.utils'; import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences'; @@ -81,6 +83,9 @@ export const useWaterData = () => { const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({}); const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); + // Redux dispatch + const dispatch = useAppDispatch(); + // 获取指定日期的记录 const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => { setLoading(prev => ({ ...prev, records: true })); @@ -196,6 +201,15 @@ export const useWaterData = () => { return false; } + // 同步到服务端(后台执行,不阻塞 UI) + dispatch(createWaterRecordAction({ + amount, + recordedAt: recordTime, + source: WaterRecordSource.Manual, + })).catch((err) => { + console.warn('同步饮水记录到服务端失败:', err); + }); + // 重新获取当前日期的数据以刷新界面 const updatedRecords = await getWaterRecordsByDate(date); const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0); @@ -225,7 +239,7 @@ export const useWaterData = () => { return false; } }, - [dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress] + [dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch] ); // 更新喝水记录(HealthKit不支持更新,只能删除后重新添加) @@ -554,6 +568,7 @@ export const useWaterDataByDate = (targetDate?: string) => { // 创建喝水记录 const reportWaterChallengeProgress = useWaterChallengeProgressReporter(); + const dispatch = useAppDispatch(); const addWaterRecord = useCallback( async (amount: number, recordedAt?: string) => { @@ -567,6 +582,15 @@ export const useWaterDataByDate = (targetDate?: string) => { return false; } + // 同步到服务端(后台执行,不阻塞 UI) + dispatch(createWaterRecordAction({ + amount, + recordedAt: recordTime, + source: WaterRecordSource.Manual, + })).catch((err) => { + console.warn('同步饮水记录到服务端失败:', err); + }); + // 重新获取当前日期的数据以刷新界面 const updatedRecords = await getWaterRecordsByDate(dateToUse); const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0); @@ -596,7 +620,7 @@ export const useWaterDataByDate = (targetDate?: string) => { return false; } }, - [dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress] + [dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch] ); // 更新喝水记录 diff --git a/services/healthKitSync.ts b/services/healthKitSync.ts index bf18e51..07ec399 100644 --- a/services/healthKitSync.ts +++ b/services/healthKitSync.ts @@ -8,16 +8,26 @@ */ import { + fetchActiveEnergyBurned, + fetchBasalEnergyBurned, + fetchCompleteSleepData, + fetchHourlyExerciseMinutesForDate, + fetchHourlyStandHoursForDate, + fetchOxygenSaturation, fetchPersonalHealthData, + fetchSmartHRVData, saveHeight, saveWeight } from '@/utils/health'; import AsyncStorage from '@/utils/kvStore'; +import { convertHrvToStressIndex } from '@/utils/stress'; import dayjs from 'dayjs'; +import { DailyHealthDataDto, updateDailyHealthData } from './users'; // 同步状态存储键 const SYNC_STATUS_KEY = '@healthkit_sync_status'; const SYNC_LOCK_KEY = '@healthkit_sync_lock'; +const DAILY_HEALTH_SYNC_KEY = '@daily_health_sync_status'; // 同步状态类型 interface SyncStatus { @@ -30,6 +40,13 @@ interface SyncStatus { }; } +// 每日健康数据同步状态 +interface DailyHealthSyncStatus { + lastSyncTime: number; + lastSyncDate: string; // YYYY-MM-DD + data: DailyHealthDataDto; +} + // 同步锁(防止并发同步) let syncLock = false; @@ -444,4 +461,150 @@ export async function clearSyncStatus(): Promise { */ export async function getSyncStatusInfo(): Promise { return getLastSyncStatus(); +} + +/** + * 同步每日健康数据报表到服务端 + * @param waterIntake - 当日饮水量(从应用内部获取,因为 HealthKit 可能不包含应用内记录) + */ +export async function syncDailyHealthReport(waterIntake?: number): Promise { + console.log('=== 开始同步每日健康报表 ==='); + + try { + const today = new Date(); + const dateStr = dayjs(today).format('YYYY-MM-DD'); + + // 1. 获取各项健康数据 + // 并行获取以提高性能 + const [ + activeEnergy, + basalEnergy, + sleepData, + exerciseMinutesData, + standHoursData, + oxygenSaturation, + hrvData + ] = await Promise.all([ + // 卡路里 + fetchActiveEnergyBurned({ + startDate: dayjs(today).startOf('day').toISOString(), + endDate: dayjs(today).endOf('day').toISOString() + }), + // 基础代谢 + fetchBasalEnergyBurned({ + startDate: dayjs(today).startOf('day').toISOString(), + endDate: dayjs(today).endOf('day').toISOString() + }), + // 睡眠数据 (需要完整数据来获取分钟数) + fetchCompleteSleepData(today), + // 锻炼分钟数 (按小时聚合) + fetchHourlyExerciseMinutesForDate(today), + // 站立小时 (按小时聚合) + fetchHourlyStandHoursForDate(today), + // 血氧 + fetchOxygenSaturation({ + startDate: dayjs(today).startOf('day').toISOString(), + endDate: dayjs(today).endOf('day').toISOString() + }), + // HRV (用于计算压力) + fetchSmartHRVData(today) + ]); + + // 2. 数据处理与计算 + + // 计算总锻炼分钟数 + const totalExerciseMinutes = exerciseMinutesData.reduce((sum, item) => sum + item.minutes, 0); + + // 计算总站立时间 (分钟) - 注意 HealthKit 返回的是小时是否有站立,我们这里估算每小时站立1分钟或者直接用小时数 + // 根据 API 要求 "standingMinutes",HealthKit 的 standHours 是指有多少个小时有站立活动 + // 通常 Apple Watch 判定一小时内站立至少1分钟即计为1个站立小时 + // 为了符合 API 语义,我们这里统计有多少个小时达标,转换成分钟可能不太准确,但 API 字段叫 minutes + // 策略:如果有 HealthKit 数据,我们用 达标小时数 * 60 作为估算,或者直接传小时数? + // 文档说 "standingMinutes: 站立时间(分钟)"。 + // HealthKit 的 appleStandHours 是 count,比如 12。 + // 如果我们传 12 分钟显然不对。如果我们传 12 * 60 = 720 分钟也不太对,因为并不是站了这么久。 + // 实际上 Apple 的 Stand Hours 是 "Hours with >1 min standing". + // 这里我们统计有多少小时是有站立的,并乘以一个系数?或者直接传小时数让后端理解? + // 鉴于字段名是 minutes,我们统计所有有站立的小时数。 + // 实际上,我们应该直接使用 HealthKit 的 appleStandTime (如果可用) 或者通过 appleStandHours 估算。 + // 这里的 fetchHourlyStandHoursForDate 返回的是每小时是否有站立(0或1)。 + const standHoursCount = standHoursData.filter(h => h.hasStood > 0).length; + // 暂时策略:将小时数转换为分钟数传递,虽然这代表的是跨度 + const standingMinutes = standHoursCount * 60; + + // 计算睡眠分钟数 + const sleepMinutes = sleepData ? Math.round(sleepData.totalSleepTime) : 0; + + // 计算压力值 + let stressLevel = 0; + if (hrvData && hrvData.value > 0) { + const stressIndex = convertHrvToStressIndex(hrvData.value); + if (stressIndex !== null) { + stressLevel = stressIndex; + } + } + + // 3. 构建 DTO + const healthData: DailyHealthDataDto = { + date: dateStr, + // 只有当数据有效时才包含字段 + ...(waterIntake !== undefined && { waterIntake }), + ...(totalExerciseMinutes > 0 && { exerciseMinutes: Math.round(totalExerciseMinutes) }), + ...(activeEnergy > 0 && { caloriesBurned: Math.round(activeEnergy) }), + ...(standingMinutes > 0 && { standingMinutes }), + ...(basalEnergy > 0 && { basalMetabolism: Math.round(basalEnergy) }), + ...(sleepMinutes > 0 && { sleepMinutes }), + ...(oxygenSaturation !== null && oxygenSaturation > 0 && { bloodOxygen: oxygenSaturation }), + ...(stressLevel > 0 && { stressLevel }) + }; + + console.log('准备同步每日健康数据:', healthData); + + // 4. 检查是否需要同步 (与上次同步的数据比较) + const lastSyncStatusStr = await AsyncStorage.getItem(DAILY_HEALTH_SYNC_KEY); + if (lastSyncStatusStr) { + const lastSyncStatus: DailyHealthSyncStatus = JSON.parse(lastSyncStatusStr); + + // 如果是同一天,检查数据差异 + if (lastSyncStatus.lastSyncDate === dateStr) { + const lastData = lastSyncStatus.data; + const isDifferent = + healthData.waterIntake !== lastData.waterIntake || + healthData.exerciseMinutes !== lastData.exerciseMinutes || + healthData.caloriesBurned !== lastData.caloriesBurned || + healthData.standingMinutes !== lastData.standingMinutes || + healthData.basalMetabolism !== lastData.basalMetabolism || + healthData.sleepMinutes !== lastData.sleepMinutes || + healthData.bloodOxygen !== lastData.bloodOxygen || + healthData.stressLevel !== lastData.stressLevel; + + if (!isDifferent) { + console.log('每日健康数据无变化,跳过同步'); + return true; + } + } + } + + // 5. 调用 API + if (Object.keys(healthData).length > 1) { // 至少包含 date 以外的一个字段 + await updateDailyHealthData(healthData); + console.log('每日健康数据同步成功'); + + // 6. 保存同步状态 + const newSyncStatus: DailyHealthSyncStatus = { + lastSyncTime: Date.now(), + lastSyncDate: dateStr, + data: healthData + }; + await AsyncStorage.setItem(DAILY_HEALTH_SYNC_KEY, JSON.stringify(newSyncStatus)); + return true; + } else { + console.log('没有有效的健康数据需要同步'); + return false; + } + + } catch (error) { + console.error('同步每日健康报表失败:', error); + return false; + } } \ No newline at end of file diff --git a/services/users.ts b/services/users.ts index af98d22..318a6d5 100644 --- a/services/users.ts +++ b/services/users.ts @@ -54,4 +54,24 @@ export async function updateBodyMeasurements(dto: BodyMeasurementsDto): Promise< return await api.put('/users/body-measurements', dto); } +export type DailyHealthDataDto = { + date?: string; // YYYY-MM-DD + waterIntake?: number; // ml + exerciseMinutes?: number; // minutes + caloriesBurned?: number; // kcal + standingMinutes?: number; // minutes + basalMetabolism?: number; // kcal + sleepMinutes?: number; // minutes + bloodOxygen?: number; // % (0-100) + stressLevel?: number; // ms (based on HRV) +}; + +export async function updateDailyHealthData(dto: DailyHealthDataDto): Promise<{ + code: number; + message: string; + data: any; +}> { + return await api.put('/users/daily-health', dto); +} + diff --git a/utils/health.ts b/utils/health.ts index fd5a30e..c1c39a7 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -1,3 +1,4 @@ +import { CompleteSleepData, fetchCompleteSleepData } from '@/utils/sleepHealthKit'; import dayjs from 'dayjs'; import { AppState, AppStateStatus, NativeModules } from 'react-native'; import i18n from '../i18n'; @@ -599,7 +600,7 @@ async function fetchHourlyStandHours(date: Date): Promise { } } -async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise { +export async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise { try { const result = await HealthKitManager.getActiveEnergyBurned(options); @@ -1484,10 +1485,14 @@ export async function fetchHourlyStandHoursForDate(date: Date): Promise ({ hour, - hasStood + hasStood: typeof hasStood === 'number' ? hasStood : (hasStood ? 1 : 0) })); } +// 导出获取完整睡眠数据的函数 (代理到 sleepHealthKit) +export { fetchCompleteSleepData }; +export type { CompleteSleepData }; + // 专门为活动圆环详情页获取精简的数据 export async function fetchActivityRingsForDate(date: Date): Promise { try {