feat: 支持食物库接口

This commit is contained in:
richarjiang
2025-08-29 09:41:05 +08:00
parent c15a9176f4
commit 8d567fb4cb
14 changed files with 1349 additions and 234 deletions

View File

@@ -14,9 +14,9 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useBackgroundTasks } from '@/hooks/useBackgroundTasks'; import { useBackgroundTasks } from '@/hooks/useBackgroundTasks';
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
@@ -141,8 +141,8 @@ export default function ExploreScreen() {
// 用于触发动画重置的 token当日期或数据变化时更新 // 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0); const [animToken, setAnimToken] = useState(0);
// 营养数据状态 // 从 Redux 获取营养数据
const [nutritionSummary, setNutritionSummary] = useState<NutritionSummary | null>(null); const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
const { registerTask } = useBackgroundTasks(); const { registerTask } = useBackgroundTasks();
// 心情相关状态 // 心情相关状态
@@ -239,7 +239,6 @@ export default function ExploreScreen() {
// 加载营养数据 // 加载营养数据
const loadNutritionData = async (targetDate?: Date) => { const loadNutritionData = async (targetDate?: Date) => {
try { try {
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期 // 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
let derivedDate: Date; let derivedDate: Date;
if (targetDate) { if (targetDate) {
@@ -249,23 +248,11 @@ export default function ExploreScreen() {
} }
console.log('加载营养数据...', derivedDate); console.log('加载营养数据...', derivedDate);
const data = await getDietRecords({ await dispatch(fetchDailyNutritionData(derivedDate));
startDate: dayjs(derivedDate).startOf('day').toISOString(), console.log('营养数据加载完成');
endDate: dayjs(derivedDate).endOf('day').toISOString(),
});
if (data.records.length > 0) {
const summary = calculateNutritionSummary(data.records);
summary.updatedAt = data.records[0].updatedAt;
setNutritionSummary(summary);
} else {
setNutritionSummary(null);
}
console.log('营养数据加载完成:', data);
} catch (error) { } catch (error) {
console.error('营养数据加载失败:', error); console.error('营养数据加载失败:', error);
setNutritionSummary(null);
} }
}; };

View File

@@ -1,9 +1,13 @@
import type { FoodItem } from '@/components/model/food/FoodDetailModal';
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal'; import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
import { useFoodLibrary, useFoodSearch } from '@/hooks/useFoodLibrary';
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
ActivityIndicator,
Modal,
SafeAreaView, SafeAreaView,
ScrollView, ScrollView,
StatusBar, StatusBar,
@@ -11,89 +15,10 @@ import {
Text, Text,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View
} from 'react-native'; } from 'react-native';
// 食物分类类型 // 餐次映射保持不变
export interface FoodCategory {
id: string;
name: string;
foods: FoodItem[];
}
// 模拟食物数据
const FOOD_DATA: FoodCategory[] = [
{
id: 'common',
name: '常见',
foods: [
{ id: '1', name: '无糖美式咖啡', emoji: '☕', calories: 1, unit: '100克' },
{ id: '2', name: '荷包蛋(油煎)', emoji: '🍳', calories: 195, unit: '100克' },
{ id: '3', name: '鸡蛋', emoji: '🥚', calories: 139, unit: '100克' },
{ id: '4', name: '香蕉', emoji: '🍌', calories: 93, unit: '100克' },
{ id: '5', name: '猕猴桃', emoji: '🥝', calories: 61, unit: '100克' },
{ id: '6', name: '苹果', emoji: '🍎', calories: 53, unit: '100克' },
{ id: '7', name: '草莓', emoji: '🍓', calories: 32, unit: '100克' },
{ id: '8', name: '蛋烧麦', emoji: '🥟', calories: 157, unit: '100克' },
{ id: '9', name: '米饭', emoji: '🍚', calories: 116, unit: '100克' },
{ id: '10', name: '鲜玉米', emoji: '🌽', calories: 112, unit: '100克' },
]
},
{
id: 'custom',
name: '自定义',
foods: []
},
{
id: 'favorite',
name: '收藏',
foods: []
},
{
id: 'fruits',
name: '水果蔬菜',
foods: [
{ id: '11', name: '苹果', emoji: '🍎', calories: 53, unit: '100克' },
{ id: '12', name: '香蕉', emoji: '🍌', calories: 93, unit: '100克' },
{ id: '13', name: '草莓', emoji: '🍓', calories: 32, unit: '100克' },
{ id: '14', name: '猕猴桃', emoji: '🥝', calories: 61, unit: '100克' },
]
},
{
id: 'meat',
name: '肉蛋奶',
foods: [
{ id: '15', name: '鸡蛋', emoji: '🥚', calories: 139, unit: '100克' },
{ id: '16', name: '荷包蛋(油煎)', emoji: '🍳', calories: 195, unit: '100克' },
]
},
{
id: 'beans',
name: '豆类坚果',
foods: []
},
{
id: 'drinks',
name: '零食饮料',
foods: [
{ id: '17', name: '无糖美式咖啡', emoji: '☕', calories: 1, unit: '100克' },
]
},
{
id: 'staple',
name: '主食',
foods: [
{ id: '18', name: '米饭', emoji: '🍚', calories: 116, unit: '100克' },
{ id: '19', name: '鲜玉米', emoji: '🌽', calories: 112, unit: '100克' },
{ id: '20', name: '蛋烧麦', emoji: '🥟', calories: 157, unit: '100克' },
]
},
{
id: 'vegetables',
name: '菜肴',
foods: []
}
];
// 餐次映射 // 餐次映射
const MEAL_TYPE_MAP = { const MEAL_TYPE_MAP = {
@@ -106,20 +31,53 @@ const MEAL_TYPE_MAP = {
export default function FoodLibraryScreen() { export default function FoodLibraryScreen() {
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ mealType?: string }>(); const params = useLocalSearchParams<{ mealType?: string }>();
const mealType = (params.mealType as 'breakfast' | 'lunch' | 'dinner' | 'snack') || 'breakfast'; const mealType = (params.mealType as MealType) || 'breakfast';
// Redux hooks
const { categories, loading, error, clearErrors } = useFoodLibrary();
const { searchResults, searchLoading, search, clearResults } = useFoodSearch();
// 本地状态
const [selectedCategoryId, setSelectedCategoryId] = useState('common'); const [selectedCategoryId, setSelectedCategoryId] = useState('common');
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [selectedFood, setSelectedFood] = useState<FoodItem | null>(null); const [selectedFood, setSelectedFood] = useState<FoodItem | null>(null);
const [showFoodDetail, setShowFoodDetail] = useState(false); const [showFoodDetail, setShowFoodDetail] = useState(false);
const [selectedFoodItems, setSelectedFoodItems] = useState<SelectedFoodItem[]>([]);
const [showMealSelector, setShowMealSelector] = useState(false);
const [currentMealType, setCurrentMealType] = useState<MealType>(mealType);
// 获取当前选中的分类 // 获取当前选中的分类
const selectedCategory = FOOD_DATA.find(cat => cat.id === selectedCategoryId); const selectedCategory = categories.find(cat => cat.id === selectedCategoryId);
// 过滤食物列表
const filteredFoods = selectedCategory?.foods.filter(food => // 过滤食物列表 - 优先显示搜索结果
food.name.toLowerCase().includes(searchText.toLowerCase()) const filteredFoods = useMemo(() => {
) || []; if (searchText.trim() && searchResults.length > 0) {
return searchResults;
}
if (selectedCategory) {
console.log('selectedCategory', selectedCategory);
return selectedCategory.foods
}
return [];
}, [searchText, searchResults, selectedCategory]);
// 处理搜索
useEffect(() => {
const timeoutId = setTimeout(() => {
if (searchText.trim()) {
search(searchText);
} else {
clearResults();
}
}, 300); // 防抖
return () => clearTimeout(timeoutId);
}, [searchText, search, clearResults]);
// 处理食物选择 - 显示详情弹窗 // 处理食物选择 - 显示详情弹窗
const handleSelectFood = (food: FoodItem) => { const handleSelectFood = (food: FoodItem) => {
@@ -136,18 +94,53 @@ export default function FoodLibraryScreen() {
// 处理食物保存 // 处理食物保存
const handleSaveFood = (food: FoodItem, amount: number, unit: string) => { const handleSaveFood = (food: FoodItem, amount: number, unit: string) => {
// 这里可以处理保存逻辑,比如添加到营养记录 // 计算实际热量
console.log('保存食物:', food, amount, unit); const actualCalories = Math.round((food.calories * amount) / 100);
// 创建新的选择项目
const newSelectedItem: SelectedFoodItem = {
id: `${food.id}_${Date.now()}`, // 使用时间戳确保唯一性
food,
amount,
unit,
calories: actualCalories
};
// 添加到已选择列表
setSelectedFoodItems(prev => [...prev, newSelectedItem]);
console.log('保存食物:', food, amount, unit, '热量:', actualCalories);
setShowFoodDetail(false); setShowFoodDetail(false);
router.back(); // 返回上一页
}; };
// 移除已选择的食物
const handleRemoveSelectedFood = (itemId: string) => {
setSelectedFoodItems(prev => prev.filter(item => item.id !== itemId));
};
// 计算总热量
const totalCalories = selectedFoodItems.reduce((sum, item) => sum + item.calories, 0);
// 关闭详情弹窗 // 关闭详情弹窗
const handleCloseFoodDetail = () => { const handleCloseFoodDetail = () => {
setShowFoodDetail(false); setShowFoodDetail(false);
setSelectedFood(null); setSelectedFood(null);
}; };
// 处理餐次选择
const handleMealTypeSelect = (selectedMealType: MealType) => {
setCurrentMealType(selectedMealType);
setShowMealSelector(false);
};
// 餐次选择选项
const mealOptions = [
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
];
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#F8F9FA" /> <StatusBar barStyle="dark-content" backgroundColor="#F8F9FA" />
@@ -158,20 +151,8 @@ export default function FoodLibraryScreen() {
<Ionicons name="chevron-back" size={24} color="#333" /> <Ionicons name="chevron-back" size={24} color="#333" />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}></Text> <Text style={styles.headerTitle}></Text>
<TouchableOpacity <TouchableOpacity style={styles.customButton}>
style={styles.customButton} <Text style={styles.customButtonText}></Text>
onPress={() => {
const testFood: FoodItem = {
id: 'test',
name: '测试食物',
emoji: '🍎',
calories: 100,
unit: '100克'
};
handleSelectFood(testFood);
}}
>
<Text style={styles.customButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -189,76 +170,223 @@ export default function FoodLibraryScreen() {
{/* 主要内容区域 - 卡片样式 */} {/* 主要内容区域 - 卡片样式 */}
<View style={styles.mainContentCard}> <View style={styles.mainContentCard}>
<View style={styles.mainContent}> {loading && categories.length === 0 ? (
{/* 左侧分类导航 */} <View style={styles.loadingContainer}>
<View style={styles.categoryContainer}> <ActivityIndicator size="large" color="#4CAF50" />
<ScrollView showsVerticalScrollIndicator={false}> <Text style={styles.loadingText}>...</Text>
{FOOD_DATA.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.categoryItem,
selectedCategoryId === category.id && styles.categoryItemActive
]}
onPress={() => setSelectedCategoryId(category.id)}
>
<Text style={[
styles.categoryText,
selectedCategoryId === category.id && styles.categoryTextActive
]}>
{category.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View> </View>
) : error ? (
{/* 右侧食物列表 */} <View style={styles.errorContainer}>
<View style={styles.foodContainer}> <Text style={styles.errorText}>{error}</Text>
<ScrollView showsVerticalScrollIndicator={false}> <TouchableOpacity
{filteredFoods.map((food) => ( style={styles.retryButton}
<View key={food.id} style={styles.foodItem}> onPress={() => {
<View style={styles.foodInfo}> clearErrors();
<Text style={styles.foodEmoji}>{food.emoji}</Text> // 这里可以重新加载数据
<View style={styles.foodDetails}> }}
<Text style={styles.foodName}>{food.name}</Text> >
<Text style={styles.foodCalories}> <Text style={styles.retryButtonText}></Text>
{food.calories}/{food.unit} </TouchableOpacity>
</Text> </View>
</View> ) : (
</View> <View style={styles.mainContent}>
{/* 左侧分类导航 */}
<View style={styles.categoryContainer}>
<ScrollView showsVerticalScrollIndicator={false}>
{categories.map((category) => (
<TouchableOpacity <TouchableOpacity
style={styles.addButton} key={category.id}
onPress={() => handleSelectFood(food)} style={[
styles.categoryItem,
selectedCategoryId === category.id && styles.categoryItemActive
]}
onPress={() => {
setSelectedCategoryId(category.id);
// 切换分类时清除搜索
if (searchText) {
setSearchText('');
clearResults();
}
}}
> >
<Ionicons name="add" size={20} color="#666" /> <Text style={[
styles.categoryText,
selectedCategoryId === category.id && styles.categoryTextActive
]}>
{category.name}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> ))}
))} </ScrollView>
</View>
{filteredFoods.length === 0 && ( {/* 右侧食物列表 */}
<View style={styles.emptyContainer}> <View style={styles.foodContainer}>
<Text style={styles.emptyText}></Text> {searchLoading ? (
<View style={styles.searchLoadingContainer}>
<ActivityIndicator size="small" color="#4CAF50" />
<Text style={styles.searchLoadingText}>...</Text>
</View> </View>
) : (
<ScrollView showsVerticalScrollIndicator={false}>
{filteredFoods.map((food) => (
<View key={food.id} style={styles.foodItem}>
<View style={styles.foodInfo}>
{food.imageUrl ? <Image
style={styles.foodImage}
source={{ uri: food.imageUrl }}
cachePolicy={'memory-disk'}
/> : <Text style={styles.foodEmoji}>{food.emoji || '🍽️'}</Text>}
<View style={styles.foodDetails}>
<Text style={styles.foodName}>{food.name}</Text>
<Text style={styles.foodCalories}>
{food.calories}/{food.unit}
</Text>
</View>
</View>
<TouchableOpacity
style={styles.addButton}
onPress={() => handleSelectFood(food)}
>
<Ionicons name="add" size={20} color="#666" />
</TouchableOpacity>
</View>
))}
{filteredFoods.length === 0 && !searchLoading && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{searchText ? '未找到相关食物' : '暂无食物数据'}
</Text>
{searchText && (
<Text style={styles.emptySubText}>
使
</Text>
)}
</View>
)}
</ScrollView>
)} )}
</ScrollView> </View>
</View> </View>
</View> )}
</View> </View>
{/* 已选择食物列表 */}
{selectedFoodItems.length > 0 && (
<View style={styles.selectedFoodsContainer}>
<View style={styles.selectedFoodsHeader}>
<Text style={styles.selectedFoodsTitle}> ({selectedFoodItems.length})</Text>
<Text style={styles.totalCalories}>: {totalCalories}</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.selectedFoodsList}
contentContainerStyle={styles.selectedFoodsContent}
>
{selectedFoodItems.map((item) => (
<View key={item.id} style={styles.selectedFoodItem}>
<TouchableOpacity
style={styles.removeButton}
onPress={() => handleRemoveSelectedFood(item.id)}
>
<Ionicons name="close-circle" size={16} color="#FF4444" />
</TouchableOpacity>
{item.food.imageUrl ? <Image
style={styles.selectedFoodImage}
source={{ uri: item.food.imageUrl }}
/> : <Text style={styles.selectedFoodEmoji}>{item.food.emoji}</Text>}
<Text style={styles.selectedFoodName} numberOfLines={1}>{item.food.name}</Text>
<Text style={styles.selectedFoodAmount}>{item.amount}{item.unit}</Text>
<Text style={styles.selectedFoodCalories}>{item.calories}</Text>
</View>
))}
</ScrollView>
</View>
)}
{/* 底部餐次选择和记录按钮 */} {/* 底部餐次选择和记录按钮 */}
<View style={styles.bottomContainer}> <View style={styles.bottomContainer}>
<View style={styles.mealSelector}> <TouchableOpacity
<View style={styles.mealIndicator} /> style={styles.mealSelector}
<Text style={styles.mealText}>{MEAL_TYPE_MAP[mealType]}</Text> onPress={() => setShowMealSelector(true)}
>
<View style={[
styles.mealIndicator,
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
]} />
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType]}</Text>
<Ionicons name="chevron-down" size={16} color="#333" /> <Ionicons name="chevron-down" size={16} color="#333" />
</View> </TouchableOpacity>
<TouchableOpacity style={styles.recordButton}> <TouchableOpacity
<Text style={styles.recordButtonText}></Text> style={[
styles.recordButton,
selectedFoodItems.length === 0 && styles.recordButtonDisabled
]}
disabled={selectedFoodItems.length === 0}
onPress={() => {
// 这里可以处理记录逻辑
console.log('记录食物:', selectedFoodItems);
// 记录成功后可以清空选择列表或返回上一页
router.back();
}}
>
<Text style={[
styles.recordButtonText,
selectedFoodItems.length === 0 && styles.recordButtonTextDisabled
]}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* 餐次选择弹窗 */}
<Modal
visible={showMealSelector}
transparent={true}
animationType="fade"
onRequestClose={() => setShowMealSelector(false)}
>
<View style={styles.mealSelectorOverlay}>
<TouchableOpacity
style={styles.mealSelectorBackdrop}
onPress={() => setShowMealSelector(false)}
/>
<View style={styles.mealSelectorModal}>
<View style={styles.mealSelectorHeader}>
<Text style={styles.mealSelectorTitle}></Text>
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<View style={styles.mealOptionsContainer}>
{mealOptions.map((option) => (
<TouchableOpacity
key={option.key}
style={[
styles.mealOption,
currentMealType === option.key && styles.mealOptionActive
]}
onPress={() => handleMealTypeSelect(option.key)}
>
<View style={[styles.mealOptionIndicator, { backgroundColor: option.color }]} />
<Text style={[
styles.mealOptionText,
currentMealType === option.key && styles.mealOptionTextActive
]}>
{option.label}
</Text>
{currentMealType === option.key && (
<Ionicons name="checkmark" size={20} color="#4CAF50" />
)}
</TouchableOpacity>
))}
</View>
</View>
</View>
</Modal>
{/* 食物详情弹窗 */} {/* 食物详情弹窗 */}
<FoodDetailModal <FoodDetailModal
visible={showFoodDetail} visible={showFoodDetail}
@@ -386,18 +514,24 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
flex: 1, flex: 1,
}, },
foodImage: {
width: 32,
height: 32,
},
foodEmoji: { foodEmoji: {
fontSize: 32, fontSize: 32,
marginRight: 12, marginRight: 12,
}, },
foodDetails: { foodDetails: {
flex: 1, flex: 1,
marginLeft: 12
}, },
foodName: { foodName: {
fontSize: 16, fontSize: 16,
color: '#333', color: '#333',
fontWeight: '500', fontWeight: '500',
marginBottom: 2, marginBottom: 2,
}, },
foodCalories: { foodCalories: {
fontSize: 14, fontSize: 14,
@@ -421,6 +555,61 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
color: '#999', color: '#999',
}, },
emptySubText: {
fontSize: 14,
color: '#CCC',
marginTop: 8,
textAlign: 'center',
},
// 加载状态样式
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
loadingText: {
fontSize: 16,
color: '#666',
marginTop: 12,
},
// 错误状态样式
errorContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingHorizontal: 20,
},
errorText: {
fontSize: 16,
color: '#FF4444',
textAlign: 'center',
marginBottom: 16,
},
retryButton: {
backgroundColor: '#4CAF50',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
retryButtonText: {
fontSize: 14,
color: '#FFF',
fontWeight: '500',
},
// 搜索加载状态样式
searchLoadingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
},
searchLoadingText: {
fontSize: 14,
color: '#666',
marginLeft: 8,
},
bottomContainer: { bottomContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -462,4 +651,147 @@ const styles = StyleSheet.create({
color: '#FFF', color: '#FFF',
fontWeight: '500', fontWeight: '500',
}, },
recordButtonDisabled: {
backgroundColor: '#CCC',
},
recordButtonTextDisabled: {
color: '#999',
},
// 已选择食物列表样式
selectedFoodsContainer: {
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E5E5E5',
paddingVertical: 12,
maxHeight: 140, // 限制最大高度
},
selectedFoodsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
},
selectedFoodsTitle: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
totalCalories: {
fontSize: 14,
color: '#4CAF50',
fontWeight: '500',
},
selectedFoodsList: {
paddingHorizontal: 16,
},
selectedFoodsContent: {
paddingRight: 16,
},
selectedFoodItem: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 8,
marginRight: 8,
minWidth: 80,
alignItems: 'center',
position: 'relative',
},
removeButton: {
position: 'absolute',
top: 4,
right: 4,
backgroundColor: '#FFF',
borderRadius: 8,
zIndex: 1,
},
selectedFoodImage: {
width: 20,
height: 20,
marginBottom: 4,
},
selectedFoodEmoji: {
fontSize: 20,
marginBottom: 4,
},
selectedFoodName: {
fontSize: 12,
color: '#333',
fontWeight: '500',
textAlign: 'center',
marginBottom: 2,
maxWidth: 64,
},
selectedFoodAmount: {
fontSize: 11,
color: '#666',
marginBottom: 2,
},
selectedFoodCalories: {
fontSize: 11,
color: '#4CAF50',
fontWeight: '500',
},
// 餐次选择弹窗样式
mealSelectorOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
mealSelectorBackdrop: {
flex: 1,
},
mealSelectorModal: {
backgroundColor: '#FFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 34, // 为底部安全区域留出空间
},
mealSelectorHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
mealSelectorTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
},
mealOptionsContainer: {
paddingHorizontal: 20,
paddingVertical: 16,
},
mealOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 8,
backgroundColor: '#F8F9FA',
},
mealOptionActive: {
backgroundColor: '#E8F5E8',
borderWidth: 1,
borderColor: '#4CAF50',
},
mealOptionIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
mealOptionText: {
flex: 1,
fontSize: 16,
color: '#333',
fontWeight: '500',
},
mealOptionTextActive: {
color: '#4CAF50',
},
}); });

View File

@@ -1,12 +1,11 @@
import { DateSelector } from '@/components/DateSelector';
import { NutritionRecordCard } from '@/components/NutritionRecordCard'; import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DateSelector } from '@/components/DateSelector';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { DietRecord, deleteDietRecord, getDietRecords } from '@/services/dietRecords'; import { DietRecord, deleteDietRecord, getDietRecords } from '@/services/dietRecords';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { import {
@@ -202,21 +201,14 @@ export default function NutritionRecordsScreen() {
const renderEmptyState = () => ( const renderEmptyState = () => (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<View style={styles.emptyTimelineContainer}> <View style={styles.emptyContent}>
<View style={styles.emptyTimeline}> <Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
<View style={[styles.emptyTimelineDot, { backgroundColor: colorTokens.primary }]}> <Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
<Ionicons name="add-outline" size={16} color="#FFFFFF" /> {viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
</View> </Text>
</View> <Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
<View style={styles.emptyContent}> {viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} /> </Text>
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
</Text>
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
{viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
</Text>
</View>
</View> </View>
</View> </View>
); );
@@ -389,33 +381,10 @@ const styles = StyleSheet.create({
paddingVertical: 60, paddingVertical: 60,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
emptyTimelineContainer: { emptyContent: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
maxWidth: 320, maxWidth: 320,
}, },
emptyTimeline: {
width: 64,
alignItems: 'center',
paddingTop: 8,
},
emptyTimelineDot: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
emptyContent: {
flex: 1,
alignItems: 'center',
marginLeft: 16,
},
emptyTitle: { emptyTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',

View File

@@ -124,7 +124,7 @@ export function NutritionRadarCard({
<View style={styles.cardRightContainer}> <View style={styles.cardRightContainer}>
<Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text> <Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
<TouchableOpacity style={styles.addButton} onPress={handleAddFood}> <TouchableOpacity style={styles.addButton} onPress={handleAddFood}>
<Ionicons name="add" size={12} color="#FFFFFF" /> <Ionicons name="add-circle" size={16} color="#FFFFFF" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>

View File

@@ -1,4 +1,4 @@
# FoodDetailModal 弹窗问题修复记录 # FoodDetailModal 弹窗问题修复记录£
## 问题描述 ## 问题描述
FoodDetailModal弹窗打开后没有内容显示 FoodDetailModal弹窗打开后没有内容显示

View File

@@ -13,15 +13,10 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
// 导入统一的食物类型定义
import type { FoodItem } from '@/types/food';
// 食物数据类型定义 // 导入统一的食物类型定义
export interface FoodItem {
id: string;
name: string;
emoji: string;
calories: number;
unit: string; // 单位,如 "100克"
}
// 营养信息接口 // 营养信息接口
interface NutritionInfo { interface NutritionInfo {
@@ -185,9 +180,8 @@ export function FoodDetailModal({
{/* 食物信息 */} {/* 食物信息 */}
<View style={styles.foodHeader}> <View style={styles.foodHeader}>
<View style={styles.foodInfo}> <View style={styles.foodInfo}>
<Text style={styles.foodEmoji}>{food.emoji}</Text> <Text style={styles.foodEmoji}>{food.emoji || '🍽️'}</Text>
<Text style={styles.foodName}>{food.name}</Text> <Text style={styles.foodName}>{food.name}</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={() => setIsFavorite(!isFavorite)} onPress={() => setIsFavorite(!isFavorite)}
@@ -255,7 +249,7 @@ export function FoodDetailModal({
{/* 保存按钮 */} {/* 保存按钮 */}
<TouchableOpacity style={styles.saveButton} onPress={handleSave}> <TouchableOpacity style={styles.saveButton} onPress={handleSave}>
<Text style={styles.saveButtonText}></Text> <Text style={styles.saveButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
</View> </View>

View File

@@ -1,3 +1,3 @@
export { FoodDetailModal } from './FoodDetailModal'; export { FoodDetailModal } from './FoodDetailModal';
export type { FoodDetailModalProps, FoodItem } from './FoodDetailModal'; export type { FoodDetailModalProps } from './FoodDetailModal';

177
hooks/useFoodLibrary.ts Normal file
View File

@@ -0,0 +1,177 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import {
addCustomFood,
addToFavorites,
clearError,
clearSearchResults,
fetchFoodLibrary,
getFoodById,
removeFoodFromCategory,
removeFromFavorites,
searchFoods,
selectCommonFoods,
selectFavoritesFoods,
selectFoodById,
selectFoodCategories,
selectFoodCategoryById,
selectFoodLibrary,
selectFoodLibraryError,
selectFoodLibraryLoading,
selectSearchLoading,
selectSearchResults,
} from '@/store/foodLibrarySlice';
import type { FoodItem } from '@/types/food';
import { useCallback, useEffect } from 'react';
/**
* 食物库自定义 Hook
* 提供食物库相关的状态和操作方法
*/
export const useFoodLibrary = () => {
const dispatch = useAppDispatch();
// 选择器
const foodLibrary = useAppSelector(selectFoodLibrary);
const categories = useAppSelector(selectFoodCategories);
const loading = useAppSelector(selectFoodLibraryLoading);
const error = useAppSelector(selectFoodLibraryError);
const searchResults = useAppSelector(selectSearchResults);
const searchLoading = useAppSelector(selectSearchLoading);
// 操作方法
const loadFoodLibrary = useCallback(() => {
dispatch(fetchFoodLibrary());
}, [dispatch]);
const searchFoodItems = useCallback((keyword: string) => {
if (keyword.trim()) {
dispatch(searchFoods(keyword));
} else {
dispatch(clearSearchResults());
}
}, [dispatch]);
const getFoodDetails = useCallback((id: number) => {
dispatch(getFoodById(id));
}, [dispatch]);
const clearErrors = useCallback(() => {
dispatch(clearError());
}, [dispatch]);
const clearSearch = useCallback(() => {
dispatch(clearSearchResults());
}, [dispatch]);
const addFoodToCategory = useCallback((categoryId: string, food: FoodItem) => {
dispatch(addCustomFood({ categoryId, food }));
}, [dispatch]);
const removeFoodFromCat = useCallback((categoryId: string, foodId: string) => {
dispatch(removeFoodFromCategory({ categoryId, foodId }));
}, [dispatch]);
const addFoodToFavorites = useCallback((food: FoodItem) => {
dispatch(addToFavorites(food));
}, [dispatch]);
const removeFoodFromFavorites = useCallback((foodId: string) => {
dispatch(removeFromFavorites(foodId));
}, [dispatch]);
// 自动加载数据
useEffect(() => {
if (categories.length === 0 && !loading && !error) {
loadFoodLibrary();
}
}, [categories.length, loading, error, loadFoodLibrary]);
return {
// 状态
foodLibrary,
categories,
loading,
error,
searchResults,
searchLoading,
// 操作方法
loadFoodLibrary,
searchFoodItems,
getFoodDetails,
clearErrors,
clearSearch,
addFoodToCategory,
removeFoodFromCat,
addFoodToFavorites,
removeFoodFromFavorites,
};
};
/**
* 获取特定分类的食物
*/
export const useFoodCategory = (categoryId: string) => {
const category = useAppSelector(selectFoodCategoryById(categoryId));
return category;
};
/**
* 获取特定食物详情
*/
export const useFoodItem = (foodId: string) => {
const food = useAppSelector(selectFoodById(foodId));
return food;
};
/**
* 获取收藏的食物
*/
export const useFavoritesFoods = () => {
const favorites = useAppSelector(selectFavoritesFoods);
return favorites;
};
/**
* 获取常见食物
*/
export const useCommonFoods = () => {
const commonFoods = useAppSelector(selectCommonFoods);
return commonFoods;
};
/**
* 食物搜索 Hook
* 提供搜索相关的状态和方法
*/
export const useFoodSearch = () => {
const dispatch = useAppDispatch();
const searchResults = useAppSelector(selectSearchResults);
const searchLoading = useAppSelector(selectSearchLoading);
const error = useAppSelector(selectFoodLibraryError);
const search = useCallback((keyword: string) => {
if (keyword.trim()) {
dispatch(searchFoods(keyword));
} else {
dispatch(clearSearchResults());
}
}, [dispatch]);
const clearResults = useCallback(() => {
dispatch(clearSearchResults());
}, [dispatch]);
const clearErrors = useCallback(() => {
dispatch(clearError());
}, [dispatch]);
return {
searchResults,
searchLoading,
error,
search,
clearResults,
clearErrors,
};
};

View File

@@ -65,11 +65,11 @@ export async function getDietRecords({
total: number total: number
page: number page: number
limit: number limit: number
}>(`/users/diet-records${params}`); }>(`/diet-records${params}`);
} }
export async function deleteDietRecord(recordId: number): Promise<void> { export async function deleteDietRecord(recordId: number): Promise<void> {
await api.delete(`/users/diet-records/${recordId}`); await api.delete(`/diet-records/${recordId}`);
} }
export function calculateNutritionSummary(records: DietRecord[]): NutritionSummary { export function calculateNutritionSummary(records: DietRecord[]): NutritionSummary {

View File

@@ -0,0 +1,49 @@
import type {
FoodItemDto,
FoodLibraryResponseDto,
GetFoodByIdParams,
SearchFoodsParams
} from '@/types/food';
import { api } from './api';
/**
* 食物库 API 服务
*/
export class FoodLibraryApi {
private static readonly BASE_PATH = '/food-library';
/**
* 获取食物库列表
*/
static async getFoodLibrary(): Promise<FoodLibraryResponseDto> {
return api.get<FoodLibraryResponseDto>(this.BASE_PATH);
}
/**
* 搜索食物
*/
static async searchFoods(params: SearchFoodsParams): Promise<FoodItemDto[]> {
const { keyword } = params;
if (!keyword || keyword.trim().length === 0) {
return [];
}
const encodedKeyword = encodeURIComponent(keyword.trim());
return api.get<FoodItemDto[]>(`${this.BASE_PATH}/search?keyword=${encodedKeyword}`);
}
/**
* 根据ID获取食物详情
*/
static async getFoodById(params: GetFoodByIdParams): Promise<FoodItemDto> {
const { id } = params;
return api.get<FoodItemDto>(`${this.BASE_PATH}/${id}`);
}
}
// 导出便捷方法
export const foodLibraryApi = {
getFoodLibrary: () => FoodLibraryApi.getFoodLibrary(),
searchFoods: (keyword: string) => FoodLibraryApi.searchFoods({ keyword }),
getFoodById: (id: number) => FoodLibraryApi.getFoodById({ id }),
};

225
store/foodLibrarySlice.ts Normal file
View File

@@ -0,0 +1,225 @@
import { foodLibraryApi } from '@/services/foodLibraryApi';
import type {
FoodCategory,
FoodCategoryDto,
FoodItem,
FoodItemDto,
FoodLibraryState
} from '@/types/food';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
// 数据转换工具函数
const transformFoodItemDto = (dto: FoodItemDto): FoodItem => ({
id: dto.id.toString(),
name: dto.name,
emoji: '🍽️', // 默认 emoji可以根据分类或其他逻辑设置
calories: dto.caloriesPer100g || 0,
unit: '100克',
description: dto.description,
protein: dto.proteinPer100g,
carbohydrate: dto.carbohydratePer100g,
fat: dto.fatPer100g,
fiber: dto.fiberPer100g,
sugar: dto.sugarPer100g,
sodium: dto.sodiumPer100g,
additionalNutrition: dto.additionalNutrition,
imageUrl: dto.imageUrl,
});
const transformFoodCategoryDto = (dto: FoodCategoryDto): FoodCategory => ({
id: dto.key,
name: dto.name,
foods: dto.foods.map(transformFoodItemDto),
icon: dto.icon,
sortOrder: dto.sortOrder,
isSystem: dto.isSystem,
});
// 异步 thunks
export const fetchFoodLibrary = createAsyncThunk(
'foodLibrary/fetchFoodLibrary',
async (_, { rejectWithValue }) => {
try {
const response = await foodLibraryApi.getFoodLibrary();
return response;
} catch (error: any) {
return rejectWithValue(error.message || '获取食物库失败');
}
}
);
export const searchFoods = createAsyncThunk(
'foodLibrary/searchFoods',
async (keyword: string, { rejectWithValue }) => {
try {
const response = await foodLibraryApi.searchFoods(keyword);
return response;
} catch (error: any) {
return rejectWithValue(error.message || '搜索食物失败');
}
}
);
export const getFoodById = createAsyncThunk(
'foodLibrary/getFoodById',
async (id: number, { rejectWithValue }) => {
try {
const response = await foodLibraryApi.getFoodById(id);
return response;
} catch (error: any) {
return rejectWithValue(error.message || '获取食物详情失败');
}
}
);
// 初始状态
const initialState: FoodLibraryState = {
categories: [],
loading: false,
error: null,
searchResults: [],
searchLoading: false,
lastUpdated: null,
};
// 创建 slice
const foodLibrarySlice = createSlice({
name: 'foodLibrary',
initialState,
reducers: {
// 清除错误
clearError: (state) => {
state.error = null;
},
// 清除搜索结果
clearSearchResults: (state) => {
state.searchResults = [];
},
// 添加自定义食物到指定分类
addCustomFood: (state, action: PayloadAction<{ categoryId: string; food: FoodItem }>) => {
const { categoryId, food } = action.payload;
const category = state.categories.find(cat => cat.id === categoryId);
if (category) {
category.foods.push(food);
}
},
// 从指定分类移除食物
removeFoodFromCategory: (state, action: PayloadAction<{ categoryId: string; foodId: string }>) => {
const { categoryId, foodId } = action.payload;
const category = state.categories.find(cat => cat.id === categoryId);
if (category) {
category.foods = category.foods.filter(food => food.id !== foodId);
}
},
// 添加食物到收藏
addToFavorites: (state, action: PayloadAction<FoodItem>) => {
const favoriteCategory = state.categories.find(cat => cat.id === 'favorite');
if (favoriteCategory) {
// 检查是否已存在
const exists = favoriteCategory.foods.some(food => food.id === action.payload.id);
if (!exists) {
favoriteCategory.foods.push(action.payload);
}
}
},
// 从收藏移除食物
removeFromFavorites: (state, action: PayloadAction<string>) => {
const favoriteCategory = state.categories.find(cat => cat.id === 'favorite');
if (favoriteCategory) {
favoriteCategory.foods = favoriteCategory.foods.filter(food => food.id !== action.payload);
}
},
},
extraReducers: (builder) => {
// 获取食物库
builder
.addCase(fetchFoodLibrary.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchFoodLibrary.fulfilled, (state, action) => {
state.loading = false;
state.categories = action.payload.categories.map(transformFoodCategoryDto);
state.lastUpdated = Date.now();
state.error = null;
})
.addCase(fetchFoodLibrary.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
// 搜索食物
builder
.addCase(searchFoods.pending, (state) => {
state.searchLoading = true;
state.error = null;
})
.addCase(searchFoods.fulfilled, (state, action) => {
state.searchLoading = false;
state.searchResults = action.payload.map(transformFoodItemDto);
state.error = null;
})
.addCase(searchFoods.rejected, (state, action) => {
state.searchLoading = false;
state.error = action.payload as string;
state.searchResults = [];
});
// 获取食物详情
builder
.addCase(getFoodById.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(getFoodById.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
// 可以在这里处理获取到的食物详情,比如更新缓存等
})
.addCase(getFoodById.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
// 导出 actions
export const {
clearError,
clearSearchResults,
addCustomFood,
removeFoodFromCategory,
addToFavorites,
removeFromFavorites,
} = foodLibrarySlice.actions;
// 选择器
export const selectFoodLibrary = (state: { foodLibrary: FoodLibraryState }) => state.foodLibrary;
export const selectFoodCategories = (state: { foodLibrary: FoodLibraryState }) => state.foodLibrary.categories;
export const selectFoodLibraryLoading = (state: { foodLibrary: FoodLibraryState }) => state.foodLibrary.loading;
export const selectFoodLibraryError = (state: { foodLibrary: FoodLibraryState }) => state.foodLibrary.error;
export const selectSearchResults = (state: { foodLibrary: FoodLibraryState }) => state.foodLibrary.searchResults;
export const selectSearchLoading = (state: { foodLibrary: FoodLibraryState }) => state.foodLibrary.searchLoading;
// 复合选择器
export const selectFoodCategoryById = (categoryId: string) =>
(state: { foodLibrary: FoodLibraryState }) =>
state.foodLibrary.categories.find(cat => cat.id === categoryId);
export const selectFoodById = (foodId: string) =>
(state: { foodLibrary: FoodLibraryState }) => {
for (const category of state.foodLibrary.categories) {
const food = category.foods.find(f => f.id === foodId);
if (food) return food;
}
return null;
};
export const selectFavoritesFoods = (state: { foodLibrary: FoodLibraryState }) =>
state.foodLibrary.categories.find(cat => cat.id === 'favorite')?.foods || [];
export const selectCommonFoods = (state: { foodLibrary: FoodLibraryState }) =>
state.foodLibrary.categories.find(cat => cat.id === 'common')?.foods || [];
// 导出 reducer
export default foodLibrarySlice.reducer;

View File

@@ -2,9 +2,11 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import challengeReducer from './challengeSlice'; import challengeReducer from './challengeSlice';
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
import exerciseLibraryReducer from './exerciseLibrarySlice'; import exerciseLibraryReducer from './exerciseLibrarySlice';
import foodLibraryReducer from './foodLibrarySlice';
import goalsReducer from './goalsSlice'; import goalsReducer from './goalsSlice';
import healthReducer from './healthSlice'; import healthReducer from './healthSlice';
import moodReducer from './moodSlice'; import moodReducer from './moodSlice';
import nutritionReducer from './nutritionSlice';
import scheduleExerciseReducer from './scheduleExerciseSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice';
import tasksReducer from './tasksSlice'; import tasksReducer from './tasksSlice';
import trainingPlanReducer from './trainingPlanSlice'; import trainingPlanReducer from './trainingPlanSlice';
@@ -47,10 +49,12 @@ export const store = configureStore({
goals: goalsReducer, goals: goalsReducer,
health: healthReducer, health: healthReducer,
mood: moodReducer, mood: moodReducer,
nutrition: nutritionReducer,
tasks: tasksReducer, tasks: tasksReducer,
trainingPlan: trainingPlanReducer, trainingPlan: trainingPlanReducer,
scheduleExercise: scheduleExerciseReducer, scheduleExercise: scheduleExerciseReducer,
exerciseLibrary: exerciseLibraryReducer, exerciseLibrary: exerciseLibraryReducer,
foodLibrary: foodLibraryReducer,
workout: workoutReducer, workout: workoutReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>

288
store/nutritionSlice.ts Normal file
View File

@@ -0,0 +1,288 @@
import { calculateNutritionSummary, deleteDietRecord, DietRecord, getDietRecords, NutritionSummary } from '@/services/dietRecords';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import dayjs from 'dayjs';
// 营养数据状态类型定义
export interface NutritionState {
// 按日期存储的营养记录
recordsByDate: Record<string, DietRecord[]>;
// 按日期存储的营养摘要
summaryByDate: Record<string, NutritionSummary>;
// 加载状态
loading: {
records: boolean;
delete: boolean;
};
// 错误信息
error: string | null;
// 分页信息
pagination: {
page: number;
limit: number;
total: number;
hasMore: boolean;
};
// 最后更新时间
lastUpdateTime: string | null;
}
// 初始状态
const initialState: NutritionState = {
recordsByDate: {},
summaryByDate: {},
loading: {
records: false,
delete: false,
},
error: null,
pagination: {
page: 1,
limit: 10,
total: 0,
hasMore: true,
},
lastUpdateTime: null,
};
// 异步操作:获取营养记录
export const fetchNutritionRecords = createAsyncThunk(
'nutrition/fetchRecords',
async (params: {
startDate?: string;
endDate?: string;
page?: number;
limit?: number;
append?: boolean;
}, { rejectWithValue }) => {
try {
const { startDate, endDate, page = 1, limit = 10, append = false } = params;
const response = await getDietRecords({
startDate,
endDate,
page,
limit,
});
return {
...response,
append,
dateKey: startDate ? dayjs(startDate).format('YYYY-MM-DD') : null,
};
} catch (error: any) {
return rejectWithValue(error.message || '获取营养记录失败');
}
}
);
// 异步操作:删除营养记录
export const deleteNutritionRecord = createAsyncThunk(
'nutrition/deleteRecord',
async (params: { recordId: number; dateKey: string }, { rejectWithValue }) => {
try {
await deleteDietRecord(params.recordId);
return params;
} catch (error: any) {
return rejectWithValue(error.message || '删除营养记录失败');
}
}
);
// 异步操作:获取指定日期的营养数据
export const fetchDailyNutritionData = createAsyncThunk(
'nutrition/fetchDailyData',
async (date: Date, { rejectWithValue }) => {
try {
const dateString = dayjs(date).format('YYYY-MM-DD');
const startDate = dayjs(date).startOf('day').toISOString();
const endDate = dayjs(date).endOf('day').toISOString();
const response = await getDietRecords({
startDate,
endDate,
limit: 100, // 获取当天所有记录
});
// 计算营养摘要
let summary: NutritionSummary | null = null;
if (response.records.length > 0) {
summary = calculateNutritionSummary(response.records);
summary.updatedAt = response.records[0].updatedAt;
}
return {
dateKey: dateString,
records: response.records,
summary,
};
} catch (error: any) {
return rejectWithValue(error.message || '获取营养数据失败');
}
}
);
const nutritionSlice = createSlice({
name: 'nutrition',
initialState,
reducers: {
// 清除错误
clearError: (state) => {
state.error = null;
},
// 清除指定日期的数据
clearDataForDate: (state, action: PayloadAction<string>) => {
const dateKey = action.payload;
delete state.recordsByDate[dateKey];
delete state.summaryByDate[dateKey];
},
// 清除所有数据
clearAllData: (state) => {
state.recordsByDate = {};
state.summaryByDate = {};
state.error = null;
state.lastUpdateTime = null;
state.pagination = initialState.pagination;
},
// 重置分页
resetPagination: (state) => {
state.pagination = initialState.pagination;
},
},
extraReducers: (builder) => {
// fetchNutritionRecords
builder
.addCase(fetchNutritionRecords.pending, (state) => {
state.loading.records = true;
state.error = null;
})
.addCase(fetchNutritionRecords.fulfilled, (state, action) => {
state.loading.records = false;
const { records, total, page, limit, append, dateKey } = action.payload;
// 更新分页信息
state.pagination = {
page,
limit,
total,
hasMore: records.length === limit,
};
if (dateKey) {
// 按日期存储记录
if (append && state.recordsByDate[dateKey]) {
state.recordsByDate[dateKey] = [...state.recordsByDate[dateKey], ...records];
} else {
state.recordsByDate[dateKey] = records;
}
// 计算并存储营养摘要
if (records.length > 0) {
const summary = calculateNutritionSummary(records);
summary.updatedAt = records[0].updatedAt;
state.summaryByDate[dateKey] = summary;
} else {
delete state.summaryByDate[dateKey];
}
}
state.lastUpdateTime = new Date().toISOString();
})
.addCase(fetchNutritionRecords.rejected, (state, action) => {
state.loading.records = false;
state.error = action.payload as string;
});
// deleteNutritionRecord
builder
.addCase(deleteNutritionRecord.pending, (state) => {
state.loading.delete = true;
state.error = null;
})
.addCase(deleteNutritionRecord.fulfilled, (state, action) => {
state.loading.delete = false;
const { recordId, dateKey } = action.payload;
// 从记录中移除已删除的项
if (state.recordsByDate[dateKey]) {
state.recordsByDate[dateKey] = state.recordsByDate[dateKey].filter(
record => record.id !== recordId
);
// 重新计算营养摘要
const remainingRecords = state.recordsByDate[dateKey];
if (remainingRecords.length > 0) {
const summary = calculateNutritionSummary(remainingRecords);
summary.updatedAt = remainingRecords[0].updatedAt;
state.summaryByDate[dateKey] = summary;
} else {
delete state.summaryByDate[dateKey];
}
}
state.lastUpdateTime = new Date().toISOString();
})
.addCase(deleteNutritionRecord.rejected, (state, action) => {
state.loading.delete = false;
state.error = action.payload as string;
});
// fetchDailyNutritionData
builder
.addCase(fetchDailyNutritionData.pending, (state) => {
state.loading.records = true;
state.error = null;
})
.addCase(fetchDailyNutritionData.fulfilled, (state, action) => {
state.loading.records = false;
const { dateKey, records, summary } = action.payload;
// 存储记录和摘要
state.recordsByDate[dateKey] = records;
if (summary) {
state.summaryByDate[dateKey] = summary;
} else {
delete state.summaryByDate[dateKey];
}
state.lastUpdateTime = new Date().toISOString();
})
.addCase(fetchDailyNutritionData.rejected, (state, action) => {
state.loading.records = false;
state.error = action.payload as string;
});
},
});
// Action creators
export const {
clearError,
clearDataForDate,
clearAllData,
resetPagination,
} = nutritionSlice.actions;
// Selectors
export const selectNutritionRecordsByDate = (dateKey: string) => (state: { nutrition: NutritionState }) =>
state.nutrition.recordsByDate[dateKey] || [];
export const selectNutritionSummaryByDate = (dateKey: string) => (state: { nutrition: NutritionState }) =>
state.nutrition.summaryByDate[dateKey] || null;
export const selectNutritionLoading = (state: { nutrition: NutritionState }) =>
state.nutrition.loading;
export const selectNutritionError = (state: { nutrition: NutritionState }) =>
state.nutrition.error;
export const selectNutritionPagination = (state: { nutrition: NutritionState }) =>
state.nutrition.pagination;
export default nutritionSlice.reducer;

90
types/food.ts Normal file
View File

@@ -0,0 +1,90 @@
// 食物库相关类型定义
// 后端 API 响应类型
export interface FoodItemDto {
id: number;
name: string;
description?: string;
caloriesPer100g?: number;
proteinPer100g?: number;
carbohydratePer100g?: number;
fatPer100g?: number;
fiberPer100g?: number;
sugarPer100g?: number;
sodiumPer100g?: number;
additionalNutrition?: Record<string, any>;
isCommon: boolean;
imageUrl?: string;
sortOrder?: number;
}
export interface FoodCategoryDto {
key: string;
name: string;
icon?: string;
sortOrder?: number;
isSystem: boolean;
foods: FoodItemDto[];
}
export interface FoodLibraryResponseDto {
categories: FoodCategoryDto[];
}
// 前端使用的类型(兼容现有代码)
export interface FoodItem {
id: string;
name: string;
emoji?: string;
calories: number;
unit: string;
description?: string;
protein?: number;
carbohydrate?: number;
fat?: number;
fiber?: number;
sugar?: number;
sodium?: number;
additionalNutrition?: Record<string, any>;
imageUrl?: string;
}
export interface FoodCategory {
id: string;
name: string;
foods: FoodItem[];
icon?: string;
sortOrder?: number;
isSystem?: boolean;
}
// 已选择的食物项目
export interface SelectedFoodItem {
id: string;
food: FoodItem;
amount: number;
unit: string;
calories: number;
}
// 餐次类型
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
// 食物库状态
export interface FoodLibraryState {
categories: FoodCategory[];
loading: boolean;
error: string | null;
searchResults: FoodItem[];
searchLoading: boolean;
lastUpdated: number | null;
}
// API 请求参数
export interface SearchFoodsParams {
keyword: string;
}
export interface GetFoodByIdParams {
id: number;
}