diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index c100dbf..a48534c 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -14,9 +14,9 @@ import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useBackgroundTasks } from '@/hooks/useBackgroundTasks'; -import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords'; import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; +import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; @@ -141,8 +141,8 @@ export default function ExploreScreen() { // 用于触发动画重置的 token(当日期或数据变化时更新) const [animToken, setAnimToken] = useState(0); - // 营养数据状态 - const [nutritionSummary, setNutritionSummary] = useState(null); + // 从 Redux 获取营养数据 + const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString)); const { registerTask } = useBackgroundTasks(); // 心情相关状态 @@ -239,7 +239,6 @@ export default function ExploreScreen() { // 加载营养数据 const loadNutritionData = async (targetDate?: Date) => { try { - // 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期 let derivedDate: Date; if (targetDate) { @@ -249,23 +248,11 @@ export default function ExploreScreen() { } console.log('加载营养数据...', derivedDate); - const data = await getDietRecords({ - startDate: dayjs(derivedDate).startOf('day').toISOString(), - 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); + await dispatch(fetchDailyNutritionData(derivedDate)); + console.log('营养数据加载完成'); } catch (error) { console.error('营养数据加载失败:', error); - setNutritionSummary(null); } }; diff --git a/app/food-library.tsx b/app/food-library.tsx index d727984..fa2a33b 100644 --- a/app/food-library.tsx +++ b/app/food-library.tsx @@ -1,9 +1,13 @@ -import type { FoodItem } 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 { Image } from 'expo-image'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { + ActivityIndicator, + Modal, SafeAreaView, ScrollView, StatusBar, @@ -11,89 +15,10 @@ import { Text, TextInput, TouchableOpacity, - View, + View } 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 = { @@ -106,20 +31,53 @@ const MEAL_TYPE_MAP = { export default function FoodLibraryScreen() { const router = useRouter(); 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 [searchText, setSearchText] = useState(''); const [selectedFood, setSelectedFood] = useState(null); const [showFoodDetail, setShowFoodDetail] = useState(false); + const [selectedFoodItems, setSelectedFoodItems] = useState([]); + const [showMealSelector, setShowMealSelector] = useState(false); + const [currentMealType, setCurrentMealType] = useState(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) => { @@ -136,18 +94,53 @@ export default function FoodLibraryScreen() { // 处理食物保存 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); - 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 = () => { setShowFoodDetail(false); 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 ( @@ -158,20 +151,8 @@ export default function FoodLibraryScreen() { 食物库 - { - const testFood: FoodItem = { - id: 'test', - name: '测试食物', - emoji: '🍎', - calories: 100, - unit: '100克' - }; - handleSelectFood(testFood); - }} - > - 测试弹窗 + + 自定义 @@ -189,76 +170,223 @@ export default function FoodLibraryScreen() { {/* 主要内容区域 - 卡片样式 */} - - {/* 左侧分类导航 */} - - - {FOOD_DATA.map((category) => ( - setSelectedCategoryId(category.id)} - > - - {category.name} - - - ))} - + {loading && categories.length === 0 ? ( + + + 加载食物库中... - - {/* 右侧食物列表 */} - - - {filteredFoods.map((food) => ( - - - {food.emoji} - - {food.name} - - {food.calories}千卡/{food.unit} - - - + ) : error ? ( + + {error} + { + clearErrors(); + // 这里可以重新加载数据 + }} + > + 重试 + + + ) : ( + + {/* 左侧分类导航 */} + + + {categories.map((category) => ( handleSelectFood(food)} + key={category.id} + style={[ + styles.categoryItem, + selectedCategoryId === category.id && styles.categoryItemActive + ]} + onPress={() => { + setSelectedCategoryId(category.id); + // 切换分类时清除搜索 + if (searchText) { + setSearchText(''); + clearResults(); + } + }} > - + + {category.name} + - - ))} + ))} + + - {filteredFoods.length === 0 && ( - - 暂无食物数据 + {/* 右侧食物列表 */} + + {searchLoading ? ( + + + 搜索中... + ) : ( + + {filteredFoods.map((food) => ( + + + {food.imageUrl ? : {food.emoji || '🍽️'}} + + {food.name} + + {food.calories}千卡/{food.unit} + + + + handleSelectFood(food)} + > + + + + ))} + + {filteredFoods.length === 0 && !searchLoading && ( + + + {searchText ? '未找到相关食物' : '暂无食物数据'} + + {searchText && ( + + 尝试使用其他关键词搜索 + + )} + + )} + )} - + - + )} + {/* 已选择食物列表 */} + {selectedFoodItems.length > 0 && ( + + + 已选择食物 ({selectedFoodItems.length}) + 总热量: {totalCalories}千卡 + + + {selectedFoodItems.map((item) => ( + + handleRemoveSelectedFood(item.id)} + > + + + + {item.food.imageUrl ? : {item.food.emoji}} + {item.food.name} + {item.amount}{item.unit} + {item.calories}千卡 + + ))} + + + )} + {/* 底部餐次选择和记录按钮 */} - - - {MEAL_TYPE_MAP[mealType]} + setShowMealSelector(true)} + > + option.key === currentMealType)?.color || '#FF6B35' } + ]} /> + {MEAL_TYPE_MAP[currentMealType]} - + - - 记录 + { + // 这里可以处理记录逻辑 + console.log('记录食物:', selectedFoodItems); + // 记录成功后可以清空选择列表或返回上一页 + router.back(); + }} + > + 记录 + {/* 餐次选择弹窗 */} + setShowMealSelector(false)} + > + + setShowMealSelector(false)} + /> + + + 选择餐次 + setShowMealSelector(false)}> + + + + + {mealOptions.map((option) => ( + handleMealTypeSelect(option.key)} + > + + + {option.label} + + {currentMealType === option.key && ( + + )} + + ))} + + + + + {/* 食物详情弹窗 */} ( - - - - - - - - - - {viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'} - - - {viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'} - - + + + + {viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'} + + + {viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'} + ); @@ -389,33 +381,10 @@ const styles = StyleSheet.create({ paddingVertical: 60, paddingHorizontal: 16, }, - emptyTimelineContainer: { - flexDirection: 'row', + emptyContent: { alignItems: 'center', 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: { fontSize: 18, fontWeight: '700', diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index 232ccd6..49693dc 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -124,7 +124,7 @@ export function NutritionRadarCard({ 更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')} - + diff --git a/components/model/food/DEBUG.md b/components/model/food/DEBUG.md index 328d006..fa898e6 100644 --- a/components/model/food/DEBUG.md +++ b/components/model/food/DEBUG.md @@ -1,4 +1,4 @@ -# FoodDetailModal 弹窗问题修复记录 +# FoodDetailModal 弹窗问题修复记录£ ## 问题描述 FoodDetailModal弹窗打开后没有内容显示 diff --git a/components/model/food/FoodDetailModal.tsx b/components/model/food/FoodDetailModal.tsx index 5a9cba5..d197eee 100644 --- a/components/model/food/FoodDetailModal.tsx +++ b/components/model/food/FoodDetailModal.tsx @@ -13,15 +13,10 @@ import { TouchableOpacity, View, } 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 { @@ -185,9 +180,8 @@ export function FoodDetailModal({ {/* 食物信息 */} - {food.emoji} + {food.emoji || '🍽️'} {food.name} - setIsFavorite(!isFavorite)} @@ -255,7 +249,7 @@ export function FoodDetailModal({ {/* 保存按钮 */} - 保存 + 添加 diff --git a/components/model/food/index.ts b/components/model/food/index.ts index 66fa7b8..6ca3364 100644 --- a/components/model/food/index.ts +++ b/components/model/food/index.ts @@ -1,3 +1,3 @@ export { FoodDetailModal } from './FoodDetailModal'; -export type { FoodDetailModalProps, FoodItem } from './FoodDetailModal'; +export type { FoodDetailModalProps } from './FoodDetailModal'; diff --git a/hooks/useFoodLibrary.ts b/hooks/useFoodLibrary.ts new file mode 100644 index 0000000..7b2901f --- /dev/null +++ b/hooks/useFoodLibrary.ts @@ -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, + }; +}; \ No newline at end of file diff --git a/services/dietRecords.ts b/services/dietRecords.ts index 2e707a4..a32d6ae 100644 --- a/services/dietRecords.ts +++ b/services/dietRecords.ts @@ -65,11 +65,11 @@ export async function getDietRecords({ total: number page: number limit: number - }>(`/users/diet-records${params}`); + }>(`/diet-records${params}`); } export async function deleteDietRecord(recordId: number): Promise { - await api.delete(`/users/diet-records/${recordId}`); + await api.delete(`/diet-records/${recordId}`); } export function calculateNutritionSummary(records: DietRecord[]): NutritionSummary { diff --git a/services/foodLibraryApi.ts b/services/foodLibraryApi.ts new file mode 100644 index 0000000..e7c8512 --- /dev/null +++ b/services/foodLibraryApi.ts @@ -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 { + return api.get(this.BASE_PATH); + } + + /** + * 搜索食物 + */ + static async searchFoods(params: SearchFoodsParams): Promise { + const { keyword } = params; + if (!keyword || keyword.trim().length === 0) { + return []; + } + + const encodedKeyword = encodeURIComponent(keyword.trim()); + return api.get(`${this.BASE_PATH}/search?keyword=${encodedKeyword}`); + } + + /** + * 根据ID获取食物详情 + */ + static async getFoodById(params: GetFoodByIdParams): Promise { + const { id } = params; + return api.get(`${this.BASE_PATH}/${id}`); + } +} + +// 导出便捷方法 +export const foodLibraryApi = { + getFoodLibrary: () => FoodLibraryApi.getFoodLibrary(), + searchFoods: (keyword: string) => FoodLibraryApi.searchFoods({ keyword }), + getFoodById: (id: number) => FoodLibraryApi.getFoodById({ id }), +}; \ No newline at end of file diff --git a/store/foodLibrarySlice.ts b/store/foodLibrarySlice.ts new file mode 100644 index 0000000..986f098 --- /dev/null +++ b/store/foodLibrarySlice.ts @@ -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) => { + 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) => { + 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; \ No newline at end of file diff --git a/store/index.ts b/store/index.ts index 4049e34..bb67ed3 100644 --- a/store/index.ts +++ b/store/index.ts @@ -2,9 +2,11 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import challengeReducer from './challengeSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import exerciseLibraryReducer from './exerciseLibrarySlice'; +import foodLibraryReducer from './foodLibrarySlice'; import goalsReducer from './goalsSlice'; import healthReducer from './healthSlice'; import moodReducer from './moodSlice'; +import nutritionReducer from './nutritionSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice'; import tasksReducer from './tasksSlice'; import trainingPlanReducer from './trainingPlanSlice'; @@ -47,10 +49,12 @@ export const store = configureStore({ goals: goalsReducer, health: healthReducer, mood: moodReducer, + nutrition: nutritionReducer, tasks: tasksReducer, trainingPlan: trainingPlanReducer, scheduleExercise: scheduleExerciseReducer, exerciseLibrary: exerciseLibraryReducer, + foodLibrary: foodLibraryReducer, workout: workoutReducer, }, middleware: (getDefaultMiddleware) => diff --git a/store/nutritionSlice.ts b/store/nutritionSlice.ts new file mode 100644 index 0000000..342a9f4 --- /dev/null +++ b/store/nutritionSlice.ts @@ -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; + + // 按日期存储的营养摘要 + summaryByDate: Record; + + // 加载状态 + 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) => { + 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; \ No newline at end of file diff --git a/types/food.ts b/types/food.ts new file mode 100644 index 0000000..6bb4c97 --- /dev/null +++ b/types/food.ts @@ -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; + 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; + 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; +} \ No newline at end of file