feat: 支持饮水记录卡片
This commit is contained in:
@@ -11,6 +11,7 @@ import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||
import tasksReducer from './tasksSlice';
|
||||
import trainingPlanReducer from './trainingPlanSlice';
|
||||
import userReducer from './userSlice';
|
||||
import waterReducer from './waterSlice';
|
||||
import workoutReducer from './workoutSlice';
|
||||
|
||||
// 创建监听器中间件来处理自动同步
|
||||
@@ -56,6 +57,7 @@ export const store = configureStore({
|
||||
exerciseLibrary: exerciseLibraryReducer,
|
||||
foodLibrary: foodLibraryReducer,
|
||||
workout: workoutReducer,
|
||||
water: waterReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api';
|
||||
import { api, setAuthToken, STORAGE_KEYS } from '@/services/api';
|
||||
import { updateUser, UpdateUserDto } from '@/services/users';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
@@ -133,18 +133,17 @@ export const login = createAsyncThunk(
|
||||
);
|
||||
|
||||
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
|
||||
const [token, profileStr, privacyAgreedStr] = await Promise.all([
|
||||
loadPersistedToken(),
|
||||
const [profileStr, privacyAgreedStr] = await Promise.all([
|
||||
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
|
||||
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
|
||||
]);
|
||||
await setAuthToken(token);
|
||||
|
||||
let profile: UserProfile = {};
|
||||
if (profileStr) {
|
||||
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
|
||||
}
|
||||
const privacyAgreed = privacyAgreedStr === 'true';
|
||||
return { token, profile, privacyAgreed } as { token: string | null; profile: UserProfile; privacyAgreed: boolean };
|
||||
return { profile, privacyAgreed } as { profile: UserProfile; privacyAgreed: boolean };
|
||||
});
|
||||
|
||||
export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => {
|
||||
@@ -181,7 +180,6 @@ export const fetchMyProfile = createAsyncThunk('user/fetchMyProfile', async (_,
|
||||
export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data: WeightHistoryItem[] = await api.get('/api/users/weight-history');
|
||||
console.log('fetchWeightHistory', data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '获取用户体重历史记录失败');
|
||||
@@ -272,7 +270,6 @@ const userSlice = createSlice({
|
||||
state.error = (action.payload as string) ?? '登录失败';
|
||||
})
|
||||
.addCase(rehydrateUser.fulfilled, (state, action) => {
|
||||
state.token = action.payload.token;
|
||||
state.profile = action.payload.profile;
|
||||
state.privacyAgreed = action.payload.privacyAgreed;
|
||||
if (!state.profile?.name || !state.profile.name.trim()) {
|
||||
|
||||
487
store/waterSlice.ts
Normal file
487
store/waterSlice.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import {
|
||||
createWaterRecord,
|
||||
CreateWaterRecordDto,
|
||||
deleteWaterRecord,
|
||||
getTodayWaterStats,
|
||||
getWaterRecords,
|
||||
TodayWaterStats,
|
||||
updateWaterGoal,
|
||||
updateWaterRecord,
|
||||
UpdateWaterRecordDto,
|
||||
WaterRecord,
|
||||
} from '@/services/waterRecords';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import dayjs from 'dayjs';
|
||||
import { RootState } from './index';
|
||||
|
||||
// 状态接口
|
||||
interface WaterState {
|
||||
// 按日期存储的喝水记录
|
||||
waterRecords: Record<string, WaterRecord[]>;
|
||||
// 分页元数据
|
||||
waterRecordsMeta: Record<string, {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}>;
|
||||
// 今日喝水统计
|
||||
todayStats: TodayWaterStats | null;
|
||||
// 每日喝水目标
|
||||
dailyWaterGoal: number | null;
|
||||
// 当前选中的日期
|
||||
selectedDate: string;
|
||||
// 加载状态
|
||||
loading: {
|
||||
records: boolean;
|
||||
stats: boolean;
|
||||
goal: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
// 错误信息
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 初始状态
|
||||
const initialState: WaterState = {
|
||||
waterRecords: {},
|
||||
waterRecordsMeta: {},
|
||||
todayStats: null,
|
||||
dailyWaterGoal: null,
|
||||
selectedDate: dayjs().format('YYYY-MM-DD'),
|
||||
loading: {
|
||||
records: false,
|
||||
stats: false,
|
||||
goal: false,
|
||||
create: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
// 异步 actions
|
||||
|
||||
// 获取指定日期的喝水记录
|
||||
export const fetchWaterRecords = createAsyncThunk(
|
||||
'water/fetchWaterRecords',
|
||||
async ({ date, page = 1, limit = 20 }: { date: string; page?: number; limit?: number }) => {
|
||||
const response = await getWaterRecords({
|
||||
date,
|
||||
page,
|
||||
limit
|
||||
});
|
||||
|
||||
return {
|
||||
date,
|
||||
records: response.records,
|
||||
total: response.total,
|
||||
page: response.page,
|
||||
limit: response.limit,
|
||||
hasMore: response.hasMore
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// 获取指定日期范围的喝水记录
|
||||
export const fetchWaterRecordsByDateRange = createAsyncThunk(
|
||||
'water/fetchWaterRecordsByDateRange',
|
||||
async ({ startDate, endDate, page = 1, limit = 20 }: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) => {
|
||||
const response = await getWaterRecords({
|
||||
startDate,
|
||||
endDate,
|
||||
page,
|
||||
limit
|
||||
});
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
// 获取今日喝水统计
|
||||
export const fetchTodayWaterStats = createAsyncThunk(
|
||||
'water/fetchTodayWaterStats',
|
||||
async () => {
|
||||
const stats = await getTodayWaterStats();
|
||||
return stats;
|
||||
}
|
||||
);
|
||||
|
||||
// 创建喝水记录
|
||||
export const createWaterRecordAction = createAsyncThunk(
|
||||
'water/createWaterRecord',
|
||||
async (dto: CreateWaterRecordDto) => {
|
||||
const newRecord = await createWaterRecord(dto);
|
||||
|
||||
return newRecord;
|
||||
}
|
||||
);
|
||||
|
||||
// 更新喝水记录
|
||||
export const updateWaterRecordAction = createAsyncThunk(
|
||||
'water/updateWaterRecord',
|
||||
async (dto: UpdateWaterRecordDto) => {
|
||||
const updatedRecord = await updateWaterRecord(dto);
|
||||
return updatedRecord;
|
||||
}
|
||||
);
|
||||
|
||||
// 删除喝水记录
|
||||
export const deleteWaterRecordAction = createAsyncThunk(
|
||||
'water/deleteWaterRecord',
|
||||
async (id: string) => {
|
||||
await deleteWaterRecord(id);
|
||||
return id;
|
||||
}
|
||||
);
|
||||
|
||||
// 更新喝水目标
|
||||
export const updateWaterGoalAction = createAsyncThunk(
|
||||
'water/updateWaterGoal',
|
||||
async (dailyWaterGoal: number) => {
|
||||
const result = await updateWaterGoal({ dailyWaterGoal });
|
||||
return result.dailyWaterGoal;
|
||||
}
|
||||
);
|
||||
|
||||
// 创建 slice
|
||||
const waterSlice = createSlice({
|
||||
name: 'water',
|
||||
initialState,
|
||||
reducers: {
|
||||
// 设置选中的日期
|
||||
setSelectedDate: (state, action: PayloadAction<string>) => {
|
||||
state.selectedDate = action.payload;
|
||||
},
|
||||
// 清除错误
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
// 清除所有数据
|
||||
clearWaterData: (state) => {
|
||||
state.waterRecords = {};
|
||||
state.todayStats = null;
|
||||
state.error = null;
|
||||
},
|
||||
// 清除喝水记录
|
||||
clearWaterRecords: (state) => {
|
||||
state.waterRecords = {};
|
||||
state.waterRecordsMeta = {};
|
||||
},
|
||||
// 设置每日喝水目标(本地)
|
||||
setDailyWaterGoal: (state, action: PayloadAction<number>) => {
|
||||
state.dailyWaterGoal = action.payload;
|
||||
if (state.todayStats) {
|
||||
state.todayStats.dailyGoal = action.payload;
|
||||
state.todayStats.completionRate =
|
||||
(state.todayStats.totalAmount / action.payload) * 100;
|
||||
}
|
||||
},
|
||||
// 添加本地喝水记录(用于离线场景)
|
||||
addLocalWaterRecord: (state, action: PayloadAction<WaterRecord>) => {
|
||||
const record = action.payload;
|
||||
const date = dayjs(record.recordedAt || record.createdAt).format('YYYY-MM-DD');
|
||||
|
||||
if (!state.waterRecords[date]) {
|
||||
state.waterRecords[date] = [];
|
||||
}
|
||||
|
||||
// 检查是否已存在相同ID的记录
|
||||
const existingIndex = state.waterRecords[date].findIndex(r => r.id === record.id);
|
||||
if (existingIndex >= 0) {
|
||||
state.waterRecords[date][existingIndex] = record;
|
||||
} else {
|
||||
state.waterRecords[date].push(record);
|
||||
}
|
||||
|
||||
// 更新今日统计
|
||||
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
|
||||
state.todayStats.totalAmount += record.amount;
|
||||
state.todayStats.recordCount += 1;
|
||||
state.todayStats.completionRate =
|
||||
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
|
||||
}
|
||||
},
|
||||
// 更新本地喝水记录
|
||||
updateLocalWaterRecord: (state, action: PayloadAction<WaterRecord>) => {
|
||||
const updatedRecord = action.payload;
|
||||
const date = dayjs(updatedRecord.recordedAt || updatedRecord.createdAt).format('YYYY-MM-DD');
|
||||
|
||||
if (state.waterRecords[date]) {
|
||||
const index = state.waterRecords[date].findIndex(r => r.id === updatedRecord.id);
|
||||
if (index >= 0) {
|
||||
const oldRecord = state.waterRecords[date][index];
|
||||
const amountDiff = updatedRecord.amount - oldRecord.amount;
|
||||
|
||||
state.waterRecords[date][index] = updatedRecord;
|
||||
|
||||
// 更新今日统计
|
||||
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
|
||||
state.todayStats.totalAmount += amountDiff;
|
||||
state.todayStats.completionRate =
|
||||
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 删除本地喝水记录
|
||||
deleteLocalWaterRecord: (state, action: PayloadAction<{ id: string; date: string }>) => {
|
||||
const { id, date } = action.payload;
|
||||
|
||||
if (state.waterRecords[date]) {
|
||||
const recordIndex = state.waterRecords[date].findIndex(r => r.id === id);
|
||||
if (recordIndex >= 0) {
|
||||
const deletedRecord = state.waterRecords[date][recordIndex];
|
||||
|
||||
// 从记录中删除
|
||||
state.waterRecords[date] = state.waterRecords[date].filter(r => r.id !== id);
|
||||
|
||||
// 更新今日统计
|
||||
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
|
||||
state.todayStats.totalAmount -= deletedRecord.amount;
|
||||
state.todayStats.recordCount -= 1;
|
||||
state.todayStats.completionRate =
|
||||
Math.max(Math.min(state.todayStats.totalAmount / state.todayStats.dailyGoal, 1), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// fetchWaterRecords
|
||||
builder
|
||||
.addCase(fetchWaterRecords.pending, (state) => {
|
||||
state.loading.records = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchWaterRecords.fulfilled, (state, action) => {
|
||||
const { date, records, total, page, limit, hasMore } = action.payload;
|
||||
|
||||
// 如果是第一页,直接替换数据;如果是分页加载,则追加数据
|
||||
if (page === 1) {
|
||||
state.waterRecords[date] = records;
|
||||
} else {
|
||||
const existingRecords = state.waterRecords[date] || [];
|
||||
state.waterRecords[date] = [...existingRecords, ...records];
|
||||
}
|
||||
|
||||
// 更新分页元数据
|
||||
state.waterRecordsMeta[date] = {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore
|
||||
};
|
||||
|
||||
state.loading.records = false;
|
||||
})
|
||||
.addCase(fetchWaterRecords.rejected, (state, action) => {
|
||||
state.loading.records = false;
|
||||
state.error = action.error.message || '获取喝水记录失败';
|
||||
});
|
||||
|
||||
// fetchWaterRecordsByDateRange
|
||||
builder
|
||||
.addCase(fetchWaterRecordsByDateRange.pending, (state) => {
|
||||
state.loading.records = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchWaterRecordsByDateRange.fulfilled, (state, action) => {
|
||||
state.loading.records = false;
|
||||
// 这里可以根据需要处理日期范围的记录
|
||||
})
|
||||
.addCase(fetchWaterRecordsByDateRange.rejected, (state, action) => {
|
||||
state.loading.records = false;
|
||||
state.error = action.error.message || '获取喝水记录失败';
|
||||
});
|
||||
|
||||
// fetchTodayWaterStats
|
||||
builder
|
||||
.addCase(fetchTodayWaterStats.pending, (state) => {
|
||||
state.loading.stats = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchTodayWaterStats.fulfilled, (state, action) => {
|
||||
state.loading.stats = false;
|
||||
state.todayStats = action.payload;
|
||||
state.dailyWaterGoal = action.payload.dailyGoal;
|
||||
})
|
||||
.addCase(fetchTodayWaterStats.rejected, (state, action) => {
|
||||
state.loading.stats = false;
|
||||
state.error = action.error.message || '获取喝水统计失败';
|
||||
});
|
||||
|
||||
// createWaterRecord
|
||||
builder
|
||||
.addCase(createWaterRecordAction.pending, (state) => {
|
||||
state.loading.create = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(createWaterRecordAction.fulfilled, (state, action) => {
|
||||
state.loading.create = false;
|
||||
const newRecord = action.payload;
|
||||
const date = dayjs(newRecord.recordedAt || newRecord.createdAt).format('YYYY-MM-DD');
|
||||
|
||||
// 添加到对应日期的记录中
|
||||
if (!state.waterRecords[date]) {
|
||||
state.waterRecords[date] = [];
|
||||
}
|
||||
|
||||
// 检查是否已存在相同ID的记录
|
||||
const existingIndex = state.waterRecords[date].findIndex(r => r.id === newRecord.id);
|
||||
if (existingIndex >= 0) {
|
||||
state.waterRecords[date][existingIndex] = newRecord;
|
||||
} else {
|
||||
state.waterRecords[date].push(newRecord);
|
||||
}
|
||||
|
||||
// 更新今日统计
|
||||
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
|
||||
state.todayStats.totalAmount += newRecord.amount;
|
||||
state.todayStats.recordCount += 1;
|
||||
state.todayStats.completionRate =
|
||||
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
|
||||
}
|
||||
})
|
||||
.addCase(createWaterRecordAction.rejected, (state, action) => {
|
||||
state.loading.create = false;
|
||||
state.error = action.error.message || '创建喝水记录失败';
|
||||
});
|
||||
|
||||
// updateWaterRecord
|
||||
builder
|
||||
.addCase(updateWaterRecordAction.pending, (state) => {
|
||||
state.loading.update = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateWaterRecordAction.fulfilled, (state, action) => {
|
||||
state.loading.update = false;
|
||||
const updatedRecord = action.payload;
|
||||
const date = dayjs(updatedRecord.recordedAt || updatedRecord.createdAt).format('YYYY-MM-DD');
|
||||
|
||||
if (state.waterRecords[date]) {
|
||||
const index = state.waterRecords[date].findIndex(r => r.id === updatedRecord.id);
|
||||
if (index >= 0) {
|
||||
const oldRecord = state.waterRecords[date][index];
|
||||
const amountDiff = updatedRecord.amount - oldRecord.amount;
|
||||
|
||||
state.waterRecords[date][index] = updatedRecord;
|
||||
|
||||
// 更新今日统计
|
||||
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
|
||||
state.todayStats.totalAmount += amountDiff;
|
||||
state.todayStats.completionRate =
|
||||
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateWaterRecordAction.rejected, (state, action) => {
|
||||
state.loading.update = false;
|
||||
state.error = action.error.message || '更新喝水记录失败';
|
||||
});
|
||||
|
||||
// deleteWaterRecord
|
||||
builder
|
||||
.addCase(deleteWaterRecordAction.pending, (state) => {
|
||||
state.loading.delete = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteWaterRecordAction.fulfilled, (state, action) => {
|
||||
state.loading.delete = false;
|
||||
const deletedId = action.payload;
|
||||
|
||||
// 从所有日期的记录中删除
|
||||
Object.keys(state.waterRecords).forEach(date => {
|
||||
const recordIndex = state.waterRecords[date].findIndex(r => r.id === deletedId);
|
||||
if (recordIndex >= 0) {
|
||||
const deletedRecord = state.waterRecords[date][recordIndex];
|
||||
|
||||
// 更新今日统计
|
||||
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
|
||||
state.todayStats.totalAmount -= deletedRecord.amount;
|
||||
state.todayStats.recordCount -= 1;
|
||||
state.todayStats.completionRate =
|
||||
Math.max(Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100), 0);
|
||||
}
|
||||
|
||||
state.waterRecords[date] = state.waterRecords[date].filter(r => r.id !== deletedId);
|
||||
}
|
||||
});
|
||||
})
|
||||
.addCase(deleteWaterRecordAction.rejected, (state, action) => {
|
||||
state.loading.delete = false;
|
||||
state.error = action.error.message || '删除喝水记录失败';
|
||||
});
|
||||
|
||||
// updateWaterGoal
|
||||
builder
|
||||
.addCase(updateWaterGoalAction.pending, (state) => {
|
||||
state.loading.goal = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateWaterGoalAction.fulfilled, (state, action) => {
|
||||
state.loading.goal = false;
|
||||
state.dailyWaterGoal = action.payload;
|
||||
if (state.todayStats) {
|
||||
state.todayStats.dailyGoal = action.payload;
|
||||
state.todayStats.completionRate =
|
||||
Math.min((state.todayStats.totalAmount / action.payload) * 100, 100);
|
||||
}
|
||||
})
|
||||
.addCase(updateWaterGoalAction.rejected, (state, action) => {
|
||||
state.loading.goal = false;
|
||||
state.error = action.error.message || '更新喝水目标失败';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 导出 actions
|
||||
export const {
|
||||
setSelectedDate,
|
||||
clearError,
|
||||
clearWaterData,
|
||||
clearWaterRecords,
|
||||
setDailyWaterGoal,
|
||||
addLocalWaterRecord,
|
||||
updateLocalWaterRecord,
|
||||
deleteLocalWaterRecord,
|
||||
} = waterSlice.actions;
|
||||
|
||||
// 选择器函数
|
||||
export const selectWaterState = (state: RootState) => state.water;
|
||||
|
||||
// 选择今日统计
|
||||
export const selectTodayStats = (state: RootState) => selectWaterState(state).todayStats;
|
||||
|
||||
// 选择每日喝水目标
|
||||
export const selectDailyWaterGoal = (state: RootState) => selectWaterState(state).dailyWaterGoal;
|
||||
|
||||
// 选择指定日期的喝水记录
|
||||
export const selectWaterRecordsByDate = (date: string) => (state: RootState) => {
|
||||
return selectWaterState(state).waterRecords[date] || [];
|
||||
};
|
||||
|
||||
// 选择当前选中日期的喝水记录
|
||||
export const selectSelectedDateWaterRecords = (state: RootState) => {
|
||||
const selectedDate = selectWaterState(state).selectedDate;
|
||||
return selectWaterRecordsByDate(selectedDate)(state);
|
||||
};
|
||||
|
||||
// 选择加载状态
|
||||
export const selectWaterLoading = (state: RootState) => selectWaterState(state).loading;
|
||||
|
||||
// 选择错误信息
|
||||
export const selectWaterError = (state: RootState) => selectWaterState(state).error;
|
||||
|
||||
// 选择当前选中日期
|
||||
export const selectSelectedDate = (state: RootState) => selectWaterState(state).selectedDate;
|
||||
|
||||
// 导出 reducer
|
||||
export default waterSlice.reducer;
|
||||
Reference in New Issue
Block a user