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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user