480 lines
13 KiB
TypeScript
480 lines
13 KiB
TypeScript
import { ROUTES } from '@/constants/Routes';
|
||
import { useAppSelector } from '@/hooks/redux';
|
||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||
import dayjs from 'dayjs';
|
||
import { Image } from 'expo-image';
|
||
import { router } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
|
||
interface BasalMetabolismCardProps {
|
||
selectedDate?: Date;
|
||
style?: any;
|
||
}
|
||
|
||
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
|
||
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalVisible, setModalVisible] = useState(false);
|
||
|
||
// 获取用户基本信息
|
||
const userProfile = useAppSelector(selectUserProfile);
|
||
const userAge = useAppSelector(selectUserAge);
|
||
|
||
// 缓存和防抖相关
|
||
const cacheRef = useRef<Map<string, { data: number | null; timestamp: number }>>(new Map());
|
||
const loadingRef = useRef<Map<string, Promise<number | null>>>(new Map());
|
||
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
|
||
|
||
// 使用 useMemo 缓存 BMR 计算,避免每次渲染重复计算
|
||
const bmrRange = useMemo(() => {
|
||
const { gender, weight, height } = userProfile;
|
||
|
||
// 检查是否有足够的信息来计算BMR
|
||
if (!gender || !weight || !height || !userAge) {
|
||
return null;
|
||
}
|
||
|
||
// 将体重和身高转换为数字
|
||
const weightNum = parseFloat(weight);
|
||
const heightNum = parseFloat(height);
|
||
|
||
if (isNaN(weightNum) || isNaN(heightNum) || weightNum <= 0 || heightNum <= 0 || userAge <= 0) {
|
||
return null;
|
||
}
|
||
|
||
// 使用Mifflin-St Jeor公式计算BMR
|
||
let bmr: number;
|
||
if (gender === 'male') {
|
||
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge + 5;
|
||
} else {
|
||
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge - 161;
|
||
}
|
||
|
||
// 计算正常范围(±15%)
|
||
const minBMR = Math.round(bmr * 0.85);
|
||
const maxBMR = Math.round(bmr * 1.15);
|
||
|
||
return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
|
||
}, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
|
||
|
||
// 优化的数据获取函数,包含缓存和去重复请求
|
||
const fetchBasalMetabolismData = useCallback(async (date: Date): Promise<number | null> => {
|
||
const dateKey = dayjs(date).format('YYYY-MM-DD');
|
||
const now = Date.now();
|
||
|
||
// 检查缓存
|
||
const cached = cacheRef.current.get(dateKey);
|
||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||
return cached.data;
|
||
}
|
||
|
||
// 检查是否已经在请求中(防止重复请求)
|
||
const existingRequest = loadingRef.current.get(dateKey);
|
||
if (existingRequest) {
|
||
return existingRequest;
|
||
}
|
||
|
||
// 创建新的请求
|
||
const request = (async () => {
|
||
try {
|
||
const options = {
|
||
startDate: dayjs(date).startOf('day').toDate().toISOString(),
|
||
endDate: dayjs(date).endOf('day').toDate().toISOString()
|
||
};
|
||
const basalEnergy = await fetchBasalEnergyBurned(options);
|
||
const result = basalEnergy || null;
|
||
|
||
// 更新缓存
|
||
cacheRef.current.set(dateKey, { data: result, timestamp: now });
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
|
||
return null;
|
||
} finally {
|
||
// 清理请求记录
|
||
loadingRef.current.delete(dateKey);
|
||
}
|
||
})();
|
||
|
||
// 记录请求
|
||
loadingRef.current.set(dateKey, request);
|
||
|
||
return request;
|
||
}, []);
|
||
|
||
// 获取基础代谢数据
|
||
useEffect(() => {
|
||
if (!selectedDate) return;
|
||
|
||
let isCancelled = false;
|
||
|
||
const loadData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await fetchBasalMetabolismData(selectedDate);
|
||
if (!isCancelled) {
|
||
setBasalMetabolism(result);
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
loadData();
|
||
|
||
// 清理函数,防止组件卸载后的状态更新
|
||
return () => {
|
||
isCancelled = true;
|
||
};
|
||
}, [selectedDate, fetchBasalMetabolismData]);
|
||
// 使用 useMemo 优化状态描述计算
|
||
const status = useMemo(() => {
|
||
if (basalMetabolism === null || basalMetabolism === 0) {
|
||
return { text: '未知', color: '#9AA3AE' };
|
||
}
|
||
|
||
// 基于常见的基础代谢范围来判断状态
|
||
if (basalMetabolism >= 1800) {
|
||
return { text: '高代谢', color: '#10B981' };
|
||
} else if (basalMetabolism >= 1400) {
|
||
return { text: '正常', color: '#3B82F6' };
|
||
} else if (basalMetabolism >= 1000) {
|
||
return { text: '偏低', color: '#F59E0B' };
|
||
} else {
|
||
return { text: '较低', color: '#EF4444' };
|
||
}
|
||
}, [basalMetabolism]);
|
||
|
||
return (
|
||
<>
|
||
<TouchableOpacity
|
||
style={[styles.container, style]}
|
||
onPress={() => setModalVisible(true)}
|
||
activeOpacity={0.8}
|
||
>
|
||
{/* 头部区域 */}
|
||
<View style={styles.header}>
|
||
<View style={styles.leftSection}>
|
||
<Image
|
||
source={require('@/assets/images/icons/icon-fire.png')}
|
||
style={styles.titleIcon}
|
||
/>
|
||
<Text style={styles.title}>基础代谢</Text>
|
||
</View>
|
||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 数值显示区域 */}
|
||
<View style={styles.valueSection}>
|
||
<Text style={styles.value}>
|
||
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||
</Text>
|
||
<Text style={styles.unit}>千卡/日</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
|
||
{/* 基础代谢详情弹窗 */}
|
||
<Modal
|
||
animationType="fade"
|
||
transparent={true}
|
||
visible={modalVisible}
|
||
onRequestClose={() => setModalVisible(false)}
|
||
>
|
||
<View style={styles.modalOverlay}>
|
||
<View style={styles.modalContent}>
|
||
{/* 关闭按钮 */}
|
||
<TouchableOpacity
|
||
style={styles.closeButton}
|
||
onPress={() => setModalVisible(false)}
|
||
>
|
||
<Text style={styles.closeButtonText}>×</Text>
|
||
</TouchableOpacity>
|
||
|
||
{/* 标题 */}
|
||
<Text style={styles.modalTitle}>基础代谢</Text>
|
||
|
||
{/* 基础代谢定义 */}
|
||
<Text style={styles.modalDescription}>
|
||
基础代谢,也称基础代谢率(BMR),是指人体在完全静息状态下维持基本生命功能(心跳、呼吸、体温调节等)所需的最低能量消耗,通常以卡路里为单位。
|
||
</Text>
|
||
|
||
{/* 为什么重要 */}
|
||
<Text style={styles.sectionTitle}>为什么重要?</Text>
|
||
<Text style={styles.sectionContent}>
|
||
基础代谢占总能量消耗的60-75%,是能量平衡的基础。了解您的基础代谢有助于制定科学的营养计划、优化体重管理策略,以及评估代谢健康状态。
|
||
</Text>
|
||
|
||
{/* 正常范围 */}
|
||
<Text style={styles.sectionTitle}>正常范围</Text>
|
||
<Text style={styles.formulaText}>
|
||
- 男性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 + 5
|
||
</Text>
|
||
<Text style={styles.formulaText}>
|
||
- 女性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 - 161
|
||
</Text>
|
||
|
||
{bmrRange ? (
|
||
<>
|
||
<Text style={styles.rangeText}>您的正常区间:{bmrRange.min}-{bmrRange.max}千卡/天</Text>
|
||
<Text style={styles.rangeNote}>
|
||
(在公式基础计算值上下浮动15%都属于正常范围)
|
||
</Text>
|
||
<Text style={styles.userInfoText}>
|
||
基于您的信息:{userProfile.gender === 'male' ? '男性' : '女性'},{userAge}岁,{userProfile.height}cm,{userProfile.weight}kg
|
||
</Text>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Text style={styles.rangeText}>请完善基本信息以计算您的代谢率</Text>
|
||
<TouchableOpacity
|
||
style={styles.completeInfoButton}
|
||
onPress={() => {
|
||
setModalVisible(false);
|
||
router.push(ROUTES.PROFILE_EDIT);
|
||
}}
|
||
>
|
||
<Text style={styles.completeInfoButtonText}>前往完善资料</Text>
|
||
</TouchableOpacity>
|
||
</>
|
||
)}
|
||
|
||
{/* 提高代谢率的策略 */}
|
||
<Text style={styles.sectionTitle}>提高代谢率的策略</Text>
|
||
<Text style={styles.strategyText}>科学研究支持以下方法:</Text>
|
||
|
||
<View style={styles.strategyList}>
|
||
<Text style={styles.strategyItem}>1.增加肌肉量 (每周2-3次力量训练)</Text>
|
||
<Text style={styles.strategyItem}>2.高强度间歇训练 (HIIT)</Text>
|
||
<Text style={styles.strategyItem}>3.充分蛋白质摄入 (体重每公斤1.6-2.2g)</Text>
|
||
<Text style={styles.strategyItem}>4.保证充足睡眠 (7-9小时/晚)</Text>
|
||
<Text style={styles.strategyItem}>5.避免过度热量限制 (不低于BMR的80%)</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 20,
|
||
padding: 16,
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 4,
|
||
},
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 12,
|
||
elevation: 4,
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
},
|
||
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 16,
|
||
zIndex: 1,
|
||
},
|
||
leftSection: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
iconContainer: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 10,
|
||
backgroundColor: '#FFFFFF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 8,
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 1,
|
||
},
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 2,
|
||
elevation: 1,
|
||
},
|
||
fireIcon: {
|
||
width: 14,
|
||
height: 18,
|
||
backgroundColor: '#EF4444',
|
||
borderTopLeftRadius: 7,
|
||
borderTopRightRadius: 7,
|
||
borderBottomLeftRadius: 2,
|
||
borderBottomRightRadius: 2,
|
||
},
|
||
title: {
|
||
fontSize: 14,
|
||
color: '#0F172A',
|
||
fontWeight: '600',
|
||
},
|
||
titleIcon: {
|
||
width: 16,
|
||
height: 16,
|
||
marginRight: 4,
|
||
},
|
||
statusBadge: {
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 4,
|
||
borderRadius: 12,
|
||
},
|
||
statusText: {
|
||
fontSize: 11,
|
||
fontWeight: '600',
|
||
},
|
||
valueSection: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
zIndex: 1,
|
||
},
|
||
value: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: '#0F172A',
|
||
lineHeight: 28,
|
||
},
|
||
unit: {
|
||
fontSize: 12,
|
||
color: '#64748B',
|
||
marginLeft: 6,
|
||
},
|
||
|
||
// Modal styles
|
||
modalOverlay: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||
justifyContent: 'flex-end',
|
||
},
|
||
modalContent: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderTopLeftRadius: 20,
|
||
borderTopRightRadius: 20,
|
||
padding: 24,
|
||
maxHeight: '90%',
|
||
width: '100%',
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: -5,
|
||
},
|
||
shadowOpacity: 0.25,
|
||
shadowRadius: 20,
|
||
elevation: 10,
|
||
},
|
||
closeButton: {
|
||
position: 'absolute',
|
||
top: 16,
|
||
right: 16,
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
backgroundColor: '#F1F5F9',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1,
|
||
},
|
||
closeButtonText: {
|
||
fontSize: 20,
|
||
color: '#64748B',
|
||
fontWeight: '600',
|
||
},
|
||
modalTitle: {
|
||
fontSize: 24,
|
||
fontWeight: '700',
|
||
color: '#0F172A',
|
||
marginBottom: 16,
|
||
textAlign: 'center',
|
||
},
|
||
modalDescription: {
|
||
fontSize: 15,
|
||
color: '#475569',
|
||
lineHeight: 22,
|
||
marginBottom: 24,
|
||
},
|
||
sectionTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
color: '#0F172A',
|
||
marginBottom: 12,
|
||
marginTop: 8,
|
||
},
|
||
sectionContent: {
|
||
fontSize: 15,
|
||
color: '#475569',
|
||
lineHeight: 22,
|
||
marginBottom: 20,
|
||
},
|
||
formulaText: {
|
||
fontSize: 14,
|
||
color: '#64748B',
|
||
fontFamily: 'monospace',
|
||
marginBottom: 4,
|
||
paddingLeft: 8,
|
||
},
|
||
rangeText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: '#059669',
|
||
marginTop: 12,
|
||
marginBottom: 4,
|
||
textAlign: 'center',
|
||
},
|
||
rangeNote: {
|
||
fontSize: 12,
|
||
color: '#9CA3AF',
|
||
textAlign: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
userInfoText: {
|
||
fontSize: 13,
|
||
color: '#6B7280',
|
||
textAlign: 'center',
|
||
marginTop: 8,
|
||
marginBottom: 16,
|
||
fontStyle: 'italic',
|
||
},
|
||
strategyText: {
|
||
fontSize: 15,
|
||
color: '#475569',
|
||
marginBottom: 12,
|
||
},
|
||
strategyList: {
|
||
marginBottom: 20,
|
||
},
|
||
strategyItem: {
|
||
fontSize: 14,
|
||
color: '#64748B',
|
||
lineHeight: 20,
|
||
marginBottom: 8,
|
||
paddingLeft: 8,
|
||
},
|
||
completeInfoButton: {
|
||
backgroundColor: '#7a5af8',
|
||
borderRadius: 12,
|
||
paddingVertical: 12,
|
||
paddingHorizontal: 24,
|
||
marginTop: 16,
|
||
alignItems: 'center',
|
||
alignSelf: 'center',
|
||
},
|
||
completeInfoButtonText: {
|
||
color: '#FFFFFF',
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
});
|