feat(health): 重构营养卡片数据获取逻辑,支持基础代谢与运动消耗分离

- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据
- NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新
- BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略
- StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞
- HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit
- 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
This commit is contained in:
richarjiang
2025-09-23 10:01:50 +08:00
parent d082c66b72
commit e6dfd4d59a
11 changed files with 1115 additions and 203 deletions

View File

@@ -16,11 +16,9 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash';
@@ -37,13 +35,10 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 浮动动画组件
const FloatingCard = ({ children, delay = 0, style }: {
const FloatingCard = ({ children, style }: {
children: React.ReactNode;
delay?: number;
style?: any;
}) => {
return (
<View
style={[
@@ -60,11 +55,6 @@ const FloatingCard = ({ children, delay = 0, style }: {
export default function ExploreScreen() {
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
// 开发调试设置为true来使用mock数据
// 在真机测试时可以暂时设置为true来验证组件显示逻辑
const useMockData = false; // 改为true来启用mock数据调试
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
@@ -83,22 +73,11 @@ export default function ExploreScreen() {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 从 Redux 获取指定日期的健康数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
// 解构健康数据支持mock数据
const mockData = useMockData ? getTestHealthData('mock') : null;
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
// 从 Redux 获取营养数据
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
// 心情相关状态
const dispatch = useAppDispatch();
@@ -110,7 +89,6 @@ export default function ExploreScreen() {
// 请求状态管理,防止重复请求
const loadingRef = useRef({
health: false,
nutrition: false,
mood: false
});
@@ -119,14 +97,14 @@ export default function ExploreScreen() {
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格
// 检查数据是否需要刷新(5分钟内不重复拉取)
const shouldRefreshData = (dateKey: string, dataType: string) => {
const cacheKey = `${dateKey}-${dataType}`;
const lastUpdate = dataTimestampRef.current[cacheKey];
const now = Date.now();
// 营养数据使用更短的缓存时间,其他数据使用5分钟
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
// 使用5分钟缓存时间
const cacheTime = 5 * 60 * 1000;
return !lastUpdate || (now - lastUpdate) > cacheTime;
};
@@ -257,45 +235,6 @@ export default function ExploreScreen() {
};
// 加载营养数据
const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => {
if (!isLoggedIn) return;
// 确定要查询的日期
let derivedDate: Date;
if (targetDate) {
derivedDate = targetDate;
} else {
derivedDate = currentSelectedDate;
}
const requestKey = getDateKey(derivedDate);
// 检查是否正在加载或不需要刷新
if (loadingRef.current.nutrition) {
console.log('营养数据正在加载中,跳过重复请求');
return;
}
if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) {
console.log('营养数据缓存未过期,跳过请求');
return;
}
try {
loadingRef.current.nutrition = true;
console.log('加载营养数据...', derivedDate);
await dispatch(fetchDailyNutritionData(derivedDate));
console.log('营养数据加载完成');
// 更新缓存时间戳
updateDataTimestamp(requestKey, 'nutrition');
} catch (error) {
console.error('营养数据加载失败:', error);
} finally {
loadingRef.current.nutrition = false;
}
};
// 实际执行数据加载的方法
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
@@ -304,7 +243,6 @@ export default function ExploreScreen() {
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
loadHealthData(dateToUse, forceRefresh);
if (isLoggedIn) {
loadNutritionData(dateToUse, forceRefresh);
loadMoodData(dateToUse, forceRefresh);
// 加载喝水数据(只加载今日数据用于后台检查)
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
@@ -456,10 +394,7 @@ export default function ExploreScreen() {
{/* 营养摄入雷达图卡片 */}
<NutritionRadarCard
nutritionSummary={nutritionSummary}
burnedCalories={activeCalories || 0}
basalMetabolism={0}
activeCalories={activeCalories || 0}
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
@@ -470,7 +405,7 @@ export default function ExploreScreen() {
{/* 左列 */}
<View style={styles.masonryColumn}>
{/* 心情卡片 */}
<FloatingCard style={styles.masonryCard} delay={1500}>
<FloatingCard style={styles.masonryCard}>
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
@@ -488,7 +423,7 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard} delay={0}>
<FloatingCard style={styles.masonryCard}>
<StressMeter
curDate={currentSelectedDate}
/>
@@ -512,14 +447,14 @@ export default function ExploreScreen() {
{/* 右列 */}
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard} delay={250}>
<FloatingCard style={styles.masonryCard}>
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
</FloatingCard>
{/* 饮水记录卡片 */}
<FloatingCard style={styles.masonryCard} delay={500}>
<FloatingCard style={styles.masonryCard}>
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
@@ -528,7 +463,7 @@ export default function ExploreScreen() {
{/* 基础代谢卡片 */}
<FloatingCard style={styles.masonryCard} delay={1250}>
<FloatingCard style={styles.masonryCard}>
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
@@ -536,7 +471,7 @@ export default function ExploreScreen() {
</FloatingCard>
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard} delay={1750}>
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
style={styles.basalMetabolismCardOverride}
/>

View File

@@ -19,6 +19,7 @@ import {
selectNutritionSummaryByDate
} from '@/store/nutritionSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
@@ -73,6 +74,9 @@ export default function NutritionRecordsScreen() {
const [hasMoreData, setHasMoreData] = useState(true);
const [page, setPage] = useState(1);
// 基础代谢数据状态
const [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
// 食物添加弹窗状态
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
@@ -118,6 +122,7 @@ export default function NutritionRecordsScreen() {
// 当选中日期或视图模式变化时重新加载数据
useEffect(() => {
fetchBasalMetabolismData();
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
@@ -150,6 +155,22 @@ export default function NutritionRecordsScreen() {
}
}, [viewMode, currentSelectedDateString, dispatch]);
// 获取基础代谢数据
const fetchBasalMetabolismData = useCallback(async () => {
try {
const options = {
startDate: dayjs(currentSelectedDate).startOf('day').toDate().toISOString(),
endDate: dayjs(currentSelectedDate).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
setBasalMetabolism(basalEnergy || 1482);
} catch (error) {
console.error('获取基础代谢数据失败:', error);
setBasalMetabolism(1482); // 失败时使用默认值
}
}, [currentSelectedDate]);
const onRefresh = useCallback(async () => {
try {
setRefreshing(true);
@@ -409,7 +430,7 @@ export default function NutritionRecordsScreen() {
{/* Calorie Ring Chart */}
<CalorieRingChart
metabolism={healthData?.basalEnergyBurned || 1482}
metabolism={basalMetabolism}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}