- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据 - NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新 - BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略 - StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞 - HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit - 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
import { calculateNutritionSummary, deleteDietRecord, DietRecord, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
|
import { fetchBasalEnergyBurned, fetchHealthDataForDate, TodayHealthData } from '@/utils/health';
|
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
import dayjs from 'dayjs';
|
|
|
|
// 营养数据状态类型定义
|
|
export interface NutritionState {
|
|
// 按日期存储的营养记录
|
|
recordsByDate: Record<string, DietRecord[]>;
|
|
|
|
// 按日期存储的营养摘要
|
|
summaryByDate: Record<string, NutritionSummary>;
|
|
|
|
// 按日期存储的健康数据(基础代谢、运动消耗等)
|
|
healthDataByDate: Record<string, TodayHealthData>;
|
|
|
|
// 按日期存储的基础代谢数据
|
|
basalMetabolismByDate: Record<string, number>;
|
|
|
|
// 加载状态
|
|
loading: {
|
|
records: boolean;
|
|
delete: boolean;
|
|
healthData: boolean;
|
|
basalMetabolism: boolean;
|
|
};
|
|
|
|
// 错误信息
|
|
error: string | null;
|
|
|
|
// 分页信息
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
// 最后更新时间
|
|
lastUpdateTime: string | null;
|
|
}
|
|
|
|
// 初始状态
|
|
const initialState: NutritionState = {
|
|
recordsByDate: {},
|
|
summaryByDate: {},
|
|
healthDataByDate: {},
|
|
basalMetabolismByDate: {},
|
|
loading: {
|
|
records: false,
|
|
delete: false,
|
|
healthData: false,
|
|
basalMetabolism: 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 || '获取营养数据失败');
|
|
}
|
|
}
|
|
);
|
|
|
|
// 异步操作:获取指定日期的健康数据
|
|
export const fetchDailyHealthData = createAsyncThunk(
|
|
'nutrition/fetchHealthData',
|
|
async (date: Date, { rejectWithValue }) => {
|
|
try {
|
|
const dateString = dayjs(date).format('YYYY-MM-DD');
|
|
const healthData = await fetchHealthDataForDate(date);
|
|
|
|
return {
|
|
dateKey: dateString,
|
|
healthData,
|
|
};
|
|
} catch (error: any) {
|
|
return rejectWithValue(error.message || '获取健康数据失败');
|
|
}
|
|
}
|
|
);
|
|
|
|
// 异步操作:获取指定日期的基础代谢数据
|
|
export const fetchDailyBasalMetabolism = createAsyncThunk(
|
|
'nutrition/fetchBasalMetabolism',
|
|
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 basalMetabolism = await fetchBasalEnergyBurned({
|
|
startDate,
|
|
endDate,
|
|
});
|
|
|
|
return {
|
|
dateKey: dateString,
|
|
basalMetabolism,
|
|
};
|
|
} catch (error: any) {
|
|
return rejectWithValue(error.message || '获取基础代谢数据失败');
|
|
}
|
|
}
|
|
);
|
|
|
|
// 异步操作:获取指定日期的完整营养卡片数据(营养数据 + 健康数据)
|
|
export const fetchCompleteNutritionCardData = createAsyncThunk(
|
|
'nutrition/fetchCompleteCardData',
|
|
async (date: Date, { rejectWithValue, dispatch }) => {
|
|
try {
|
|
const dateString = dayjs(date).format('YYYY-MM-DD');
|
|
|
|
// 并行获取营养数据和健康数据
|
|
const [nutritionResult, healthResult, basalResult] = await Promise.allSettled([
|
|
dispatch(fetchDailyNutritionData(date)).unwrap(),
|
|
dispatch(fetchDailyHealthData(date)).unwrap(),
|
|
dispatch(fetchDailyBasalMetabolism(date)).unwrap(),
|
|
]);
|
|
|
|
return {
|
|
dateKey: dateString,
|
|
nutritionSuccess: nutritionResult.status === 'fulfilled',
|
|
healthSuccess: healthResult.status === 'fulfilled',
|
|
basalSuccess: basalResult.status === 'fulfilled',
|
|
};
|
|
} 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];
|
|
delete state.healthDataByDate[dateKey];
|
|
delete state.basalMetabolismByDate[dateKey];
|
|
},
|
|
|
|
// 清除所有数据
|
|
clearAllData: (state) => {
|
|
state.recordsByDate = {};
|
|
state.summaryByDate = {};
|
|
state.healthDataByDate = {};
|
|
state.basalMetabolismByDate = {};
|
|
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;
|
|
});
|
|
|
|
// fetchDailyHealthData
|
|
builder
|
|
.addCase(fetchDailyHealthData.pending, (state) => {
|
|
state.loading.healthData = true;
|
|
state.error = null;
|
|
})
|
|
.addCase(fetchDailyHealthData.fulfilled, (state, action) => {
|
|
state.loading.healthData = false;
|
|
const { dateKey, healthData } = action.payload;
|
|
state.healthDataByDate[dateKey] = healthData;
|
|
state.lastUpdateTime = new Date().toISOString();
|
|
})
|
|
.addCase(fetchDailyHealthData.rejected, (state, action) => {
|
|
state.loading.healthData = false;
|
|
state.error = action.payload as string;
|
|
});
|
|
|
|
// fetchDailyBasalMetabolism
|
|
builder
|
|
.addCase(fetchDailyBasalMetabolism.pending, (state) => {
|
|
state.loading.basalMetabolism = true;
|
|
state.error = null;
|
|
})
|
|
.addCase(fetchDailyBasalMetabolism.fulfilled, (state, action) => {
|
|
state.loading.basalMetabolism = false;
|
|
const { dateKey, basalMetabolism } = action.payload;
|
|
state.basalMetabolismByDate[dateKey] = basalMetabolism;
|
|
state.lastUpdateTime = new Date().toISOString();
|
|
})
|
|
.addCase(fetchDailyBasalMetabolism.rejected, (state, action) => {
|
|
state.loading.basalMetabolism = false;
|
|
state.error = action.payload as string;
|
|
});
|
|
|
|
// fetchCompleteNutritionCardData
|
|
builder
|
|
.addCase(fetchCompleteNutritionCardData.pending, (state) => {
|
|
state.loading.records = true;
|
|
state.loading.healthData = true;
|
|
state.loading.basalMetabolism = true;
|
|
state.error = null;
|
|
})
|
|
.addCase(fetchCompleteNutritionCardData.fulfilled, (state, action) => {
|
|
state.loading.records = false;
|
|
state.loading.healthData = false;
|
|
state.loading.basalMetabolism = false;
|
|
state.lastUpdateTime = new Date().toISOString();
|
|
})
|
|
.addCase(fetchCompleteNutritionCardData.rejected, (state, action) => {
|
|
state.loading.records = false;
|
|
state.loading.healthData = false;
|
|
state.loading.basalMetabolism = 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 selectHealthDataByDate = (dateKey: string) => (state: { nutrition: NutritionState }) =>
|
|
state.nutrition.healthDataByDate[dateKey] || null;
|
|
|
|
export const selectBasalMetabolismByDate = (dateKey: string) => (state: { nutrition: NutritionState }) =>
|
|
state.nutrition.basalMetabolismByDate[dateKey] || 0;
|
|
|
|
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 const selectNutritionCardDataByDate = (dateKey: string) => (state: { nutrition: NutritionState }) => ({
|
|
nutritionSummary: state.nutrition.summaryByDate[dateKey] || null,
|
|
healthData: state.nutrition.healthDataByDate[dateKey] || null,
|
|
basalMetabolism: state.nutrition.basalMetabolismByDate[dateKey] || 0,
|
|
loading: state.nutrition.loading,
|
|
error: state.nutrition.error,
|
|
});
|
|
|
|
export default nutritionSlice.reducer; |