feat: 支持食物库接口
This commit is contained in:
@@ -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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
setShowFoodDetail(false);
|
|
||||||
router.back(); // 返回上一页
|
// 创建新的选择项目
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除已选择的食物
|
||||||
|
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,18 +170,44 @@ export default function FoodLibraryScreen() {
|
|||||||
|
|
||||||
{/* 主要内容区域 - 卡片样式 */}
|
{/* 主要内容区域 - 卡片样式 */}
|
||||||
<View style={styles.mainContentCard}>
|
<View style={styles.mainContentCard}>
|
||||||
|
{loading && categories.length === 0 ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#4CAF50" />
|
||||||
|
<Text style={styles.loadingText}>加载食物库中...</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={() => {
|
||||||
|
clearErrors();
|
||||||
|
// 这里可以重新加载数据
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.retryButtonText}>重试</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<View style={styles.mainContent}>
|
<View style={styles.mainContent}>
|
||||||
{/* 左侧分类导航 */}
|
{/* 左侧分类导航 */}
|
||||||
<View style={styles.categoryContainer}>
|
<View style={styles.categoryContainer}>
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{FOOD_DATA.map((category) => (
|
{categories.map((category) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={category.id}
|
key={category.id}
|
||||||
style={[
|
style={[
|
||||||
styles.categoryItem,
|
styles.categoryItem,
|
||||||
selectedCategoryId === category.id && styles.categoryItemActive
|
selectedCategoryId === category.id && styles.categoryItemActive
|
||||||
]}
|
]}
|
||||||
onPress={() => setSelectedCategoryId(category.id)}
|
onPress={() => {
|
||||||
|
setSelectedCategoryId(category.id);
|
||||||
|
// 切换分类时清除搜索
|
||||||
|
if (searchText) {
|
||||||
|
setSearchText('');
|
||||||
|
clearResults();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.categoryText,
|
styles.categoryText,
|
||||||
@@ -215,11 +222,21 @@ export default function FoodLibraryScreen() {
|
|||||||
|
|
||||||
{/* 右侧食物列表 */}
|
{/* 右侧食物列表 */}
|
||||||
<View style={styles.foodContainer}>
|
<View style={styles.foodContainer}>
|
||||||
|
{searchLoading ? (
|
||||||
|
<View style={styles.searchLoadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color="#4CAF50" />
|
||||||
|
<Text style={styles.searchLoadingText}>搜索中...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{filteredFoods.map((food) => (
|
{filteredFoods.map((food) => (
|
||||||
<View key={food.id} style={styles.foodItem}>
|
<View key={food.id} style={styles.foodItem}>
|
||||||
<View style={styles.foodInfo}>
|
<View style={styles.foodInfo}>
|
||||||
<Text style={styles.foodEmoji}>{food.emoji}</Text>
|
{food.imageUrl ? <Image
|
||||||
|
style={styles.foodImage}
|
||||||
|
source={{ uri: food.imageUrl }}
|
||||||
|
cachePolicy={'memory-disk'}
|
||||||
|
/> : <Text style={styles.foodEmoji}>{food.emoji || '🍽️'}</Text>}
|
||||||
<View style={styles.foodDetails}>
|
<View style={styles.foodDetails}>
|
||||||
<Text style={styles.foodName}>{food.name}</Text>
|
<Text style={styles.foodName}>{food.name}</Text>
|
||||||
<Text style={styles.foodCalories}>
|
<Text style={styles.foodCalories}>
|
||||||
@@ -236,29 +253,140 @@ export default function FoodLibraryScreen() {
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{filteredFoods.length === 0 && (
|
{filteredFoods.length === 0 && !searchLoading && (
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Text style={styles.emptyText}>暂无食物数据</Text>
|
<Text style={styles.emptyText}>
|
||||||
|
{searchText ? '未找到相关食物' : '暂无食物数据'}
|
||||||
|
</Text>
|
||||||
|
{searchText && (
|
||||||
|
<Text style={styles.emptySubText}>
|
||||||
|
尝试使用其他关键词搜索
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -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,12 +201,6 @@ export default function NutritionRecordsScreen() {
|
|||||||
|
|
||||||
const renderEmptyState = () => (
|
const renderEmptyState = () => (
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<View style={styles.emptyTimelineContainer}>
|
|
||||||
<View style={styles.emptyTimeline}>
|
|
||||||
<View style={[styles.emptyTimelineDot, { backgroundColor: colorTokens.primary }]}>
|
|
||||||
<Ionicons name="add-outline" size={16} color="#FFFFFF" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.emptyContent}>
|
<View style={styles.emptyContent}>
|
||||||
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
|
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
|
||||||
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
|
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
|
||||||
@@ -218,7 +211,6 @@ export default function NutritionRecordsScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
|
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# FoodDetailModal 弹窗问题修复记录
|
# FoodDetailModal 弹窗问题修复记录£
|
||||||
|
|
||||||
## 问题描述
|
## 问题描述
|
||||||
FoodDetailModal弹窗打开后没有内容显示
|
FoodDetailModal弹窗打开后没有内容显示
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
177
hooks/useFoodLibrary.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
49
services/foodLibraryApi.ts
Normal file
49
services/foodLibraryApi.ts
Normal 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
225
store/foodLibrarySlice.ts
Normal 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;
|
||||||
@@ -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
288
store/nutritionSlice.ts
Normal 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
90
types/food.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user