feat: 支持食物库接口
This commit is contained in:
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 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) =>
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user