From 85a3c742df517b71aa996d56e25d016574823214 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 2 Sep 2025 15:50:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=A5=AE=E6=B0=B4?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/coach.tsx | 2 +- app/(tabs)/statistics.tsx | 17 +- components/AddWaterModal.tsx | 377 +++++++++++++++++++++++++ components/AnimatedNumber.tsx | 67 +++-- components/StepsCard.tsx | 20 +- components/WaterIntakeCard.tsx | 312 +++++++++++++++++++++ contexts/ToastContext.tsx | 3 +- hooks/useWaterData.ts | 494 +++++++++++++++++++++++++++++++++ services/api.ts | 23 +- services/notifications.ts | 3 +- services/waterRecords.ts | 167 +++++++++++ store/index.ts | 2 + store/userSlice.ts | 11 +- store/waterSlice.ts | 487 ++++++++++++++++++++++++++++++++ test-water-api.md | 135 +++++++++ utils/notificationHelpers.ts | 2 - 16 files changed, 2066 insertions(+), 56 deletions(-) create mode 100644 components/AddWaterModal.tsx create mode 100644 components/WaterIntakeCard.tsx create mode 100644 hooks/useWaterData.ts create mode 100644 services/waterRecords.ts create mode 100644 store/waterSlice.ts create mode 100644 test-water-api.md diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index a6f6366..af36d90 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -889,7 +889,7 @@ export default function CoachScreen() { }; try { - const controller = postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 }); + const controller = await postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 }); streamAbortRef.current = controller; } catch (e) { onError(e); diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 1772278..4ae7e39 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -7,6 +7,7 @@ import HeartRateCard from '@/components/statistic/HeartRateCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import StepsCard from '@/components/StepsCard'; import { StressMeter } from '@/components/StressMeter'; +import WaterIntakeCard from '@/components/WaterIntakeCard'; import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; @@ -18,7 +19,7 @@ import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; -import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHRV, fetchRecentHRV } from '@/utils/health'; +import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health'; import { getTestHealthData } from '@/utils/mockHealthData'; import { calculateNutritionGoals } from '@/utils/nutrition'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -454,6 +455,15 @@ export default function ExploreScreen() { style={styles.stepsCardOverride} /> + + {/* 饮水记录卡片 */} + + + + void; + selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD +} + +interface TabButtonProps { + title: string; + isActive: boolean; + onPress: () => void; +} + +const TabButton: React.FC = ({ title, isActive, onPress }) => ( + + + {title} + + +); + +const AddWaterModal: React.FC = ({ visible, onClose, selectedDate }) => { + const [activeTab, setActiveTab] = useState<'add' | 'goal'>('add'); + const [waterAmount, setWaterAmount] = useState('250'); + const [note, setNote] = useState(''); + const [dailyGoal, setDailyGoal] = useState('2000'); + + // 使用新的 hook 来处理指定日期的饮水数据 + const { addWaterRecord, updateWaterGoal } = useWaterDataByDate(selectedDate); + + const quickAmounts = [100, 150, 200, 250, 300, 350, 400, 500]; + const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000]; + + const handleAddWater = async () => { + const amount = parseInt(waterAmount); + if (amount > 0) { + // 如果有选中日期,则为该日期添加记录;否则为今天添加记录 + const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString(); + + const success = await addWaterRecord(amount, recordedAt); + if (success) { + setWaterAmount('250'); + setNote(''); + onClose(); + } + } + }; + + const handleUpdateGoal = async () => { + const goal = parseInt(dailyGoal); + if (goal >= 500 && goal <= 10000) { + const success = await updateWaterGoal(goal); + if (success) { + setDailyGoal('2000'); + onClose(); + } + } + }; + + const renderAddRecordTab = () => ( + + 饮水量 (ml) + + + 快速选择 + + + {quickAmounts.map((amount) => ( + setWaterAmount(amount.toString())} + > + + {amount}ml + + + ))} + + + + 备注 (可选) + + + + + 取消 + + + 添加记录 + + + + ); + + const renderGoalTab = () => ( + + 每日饮水目标 (ml) + + + 推荐目标 + + + {goalPresets.map((goal) => ( + setDailyGoal(goal.toString())} + > + + {goal}ml + + + ))} + + + + + + 取消 + + + 更新目标 + + + + ); + + return ( + + + + + 配置饮水 + + + + + + + setActiveTab('add')} + /> + setActiveTab('goal')} + /> + + + + {activeTab === 'add' ? renderAddRecordTab() : renderGoalTab()} + + + + + ); +}; + +const styles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + modalView: { + width: '90%', + maxWidth: 350, + maxHeight: '80%', + backgroundColor: 'white', + borderRadius: 20, + padding: 20, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#333', + }, + closeButton: { + padding: 5, + }, + tabContainer: { + flexDirection: 'row', + marginBottom: 20, + borderRadius: 10, + backgroundColor: '#f5f5f5', + padding: 4, + }, + tabButton: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + borderRadius: 8, + }, + activeTabButton: { + backgroundColor: '#007AFF', + }, + tabButtonText: { + fontSize: 14, + color: '#666', + fontWeight: '500', + }, + activeTabButtonText: { + color: 'white', + fontWeight: '600', + }, + contentScrollView: { + maxHeight: 400, + }, + tabContent: { + paddingVertical: 10, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 10, + }, + input: { + borderWidth: 1, + borderColor: '#e0e0e0', + borderRadius: 10, + paddingHorizontal: 15, + paddingVertical: 12, + fontSize: 16, + color: '#333', + marginBottom: 15, + }, + remarkInput: { + height: 80, + textAlignVertical: 'top', + }, + quickAmountsContainer: { + marginBottom: 15, + }, + quickAmountsWrapper: { + flexDirection: 'row', + gap: 10, + paddingRight: 10, + }, + quickAmountButton: { + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 20, + borderWidth: 1, + borderColor: '#e0e0e0', + backgroundColor: '#f9f9f9', + minWidth: 60, + alignItems: 'center', + }, + quickAmountButtonActive: { + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }, + quickAmountText: { + fontSize: 14, + color: '#666', + fontWeight: '500', + }, + quickAmountTextActive: { + color: 'white', + fontWeight: '600', + }, + buttonContainer: { + flexDirection: 'row', + gap: 10, + marginTop: 20, + }, + button: { + flex: 1, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#f5f5f5', + }, + confirmButton: { + backgroundColor: '#007AFF', + }, + cancelButtonText: { + fontSize: 16, + color: '#666', + fontWeight: '500', + }, + confirmButtonText: { + fontSize: 16, + color: 'white', + fontWeight: '600', + }, +}); + +export default AddWaterModal; \ No newline at end of file diff --git a/components/AnimatedNumber.tsx b/components/AnimatedNumber.tsx index b115455..1f8eb4c 100644 --- a/components/AnimatedNumber.tsx +++ b/components/AnimatedNumber.tsx @@ -12,36 +12,71 @@ type AnimatedNumberProps = { export function AnimatedNumber({ value, - durationMs = 800, + durationMs = 300, format, style, resetToken, }: AnimatedNumberProps) { - const animated = useRef(new Animated.Value(0)).current; + const opacity = useRef(new Animated.Value(1)).current; const [display, setDisplay] = useState('0'); + const [currentValue, setCurrentValue] = useState(0); useEffect(() => { - animated.stopAnimation(() => { - animated.setValue(0); - Animated.timing(animated, { - toValue: value, - duration: durationMs, - easing: Easing.out(Easing.cubic), + // 如果值没有变化,不执行动画 + if (value === currentValue && resetToken === undefined) { + return; + } + + // 停止当前动画 + opacity.stopAnimation(() => { + // 创建优雅的透明度变化动画 + const fadeOut = Animated.timing(opacity, { + toValue: 0.2, // 淡出到较低透明度 + duration: durationMs * 0.4, // 淡出占总时长的40% + easing: Easing.out(Easing.quad), useNativeDriver: false, - }).start(); + }); + + const fadeIn = Animated.timing(opacity, { + toValue: 1, + duration: durationMs * 0.6, // 淡入占总时长的60% + easing: Easing.out(Easing.quad), + useNativeDriver: false, + }); + + // 在淡出完成时更新数字显示 + fadeOut.start(() => { + // 更新当前值和显示 + setCurrentValue(value); + setDisplay(format ? format(value) : `${Math.round(value)}`); + + // 然后淡入新数字 + fadeIn.start(); + }); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, resetToken]); + // 初始化显示值 useEffect(() => { - const id = animated.addListener(({ value: v }) => { - const num = Number(v) || 0; - setDisplay(format ? format(num) : `${Math.round(num)}`); - }); - return () => animated.removeListener(id); - }, [animated, format]); + if (currentValue !== value) { + setCurrentValue(value); + setDisplay(format ? format(value) : `${Math.round(value)}`); + } + }, [value, format, currentValue]); - return {display}; + return ( + + {display} + + ); } diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx index 8fd0f87..3a0e40e 100644 --- a/components/StepsCard.tsx +++ b/components/StepsCard.tsx @@ -1,13 +1,14 @@ -import React, { useMemo, useRef, useEffect } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { + Animated, StyleSheet, Text, View, - ViewStyle, - Animated + ViewStyle } from 'react-native'; import { HourlyStepData } from '@/utils/health'; +import { AnimatedNumber } from './AnimatedNumber'; // 使用原生View来替代SVG,避免导入问题 // import Svg, { Rect } from 'react-native-svg'; @@ -53,7 +54,7 @@ const StepsCard: React.FC = ({ if (chartData && chartData.length > 0) { // 重置所有动画值 animatedValues.forEach(animValue => animValue.setValue(0)); - + // 同时启动所有柱体的弹性动画,有步数的柱体才执行动画 chartData.forEach((data, index) => { if (data.steps > 0) { @@ -108,7 +109,7 @@ const StepsCard: React.FC = ({ } ]} /> - + {/* 数据柱体 - 只有当有数据时才显示并执行动画 */} {isActive && ( = ({ {/* 步数和目标显示 */} - - {stepCount !== null ? stepCount.toLocaleString() : '——'} - + stepCount !== null ? `${Math.round(v)}` : '——'} + resetToken={stepCount} + /> ); diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx new file mode 100644 index 0000000..d29b725 --- /dev/null +++ b/components/WaterIntakeCard.tsx @@ -0,0 +1,312 @@ +import { useWaterDataByDate } from '@/hooks/useWaterData'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + Animated, + StyleSheet, + Text, + TouchableOpacity, + View, + ViewStyle +} from 'react-native'; +import AddWaterModal from './AddWaterModal'; + +interface WaterIntakeCardProps { + style?: ViewStyle; + selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD +} + +const WaterIntakeCard: React.FC = ({ + style, + selectedDate +}) => { + const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate); + const [isModalVisible, setIsModalVisible] = useState(false); + + // 计算当前饮水量和目标 + const currentIntake = waterStats?.totalAmount || 0; + const targetIntake = dailyWaterGoal || 2000; + + // 为每个时间点创建独立的动画值 + const animatedValues = useMemo(() => + Array.from({ length: 24 }, () => new Animated.Value(0)) + , []); + + // 计算柱状图数据 + const chartData = useMemo(() => { + if (!waterRecords || waterRecords.length === 0) { + return Array.from({ length: 24 }, (_, i) => ({ hour: i, amount: 0, height: 0 })); + } + + // 按小时分组数据 + const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + amount: 0, + })); + + waterRecords.forEach(record => { + // 优先使用 recordedAt,如果没有则使用 createdAt + const dateTime = record.recordedAt || record.createdAt; + const hour = dayjs(dateTime).hour(); + if (hour >= 0 && hour < 24) { + hourlyData[hour].amount += record.amount; + } + }); + + // 找到最大饮水量用于计算高度比例 + const maxAmount = Math.max(...hourlyData.map(data => data.amount), 1); + const maxHeight = 20; // 柱状图最大高度 + + return hourlyData.map(data => ({ + hour: data.hour, + amount: data.amount, + height: maxAmount > 0 ? (data.amount / maxAmount) * maxHeight : 0 + })); + }, [waterRecords]); + + // 获取当前小时 - 只有当选中的是今天时才显示当前小时 + const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate; + const currentHour = isToday ? new Date().getHours() : -1; // 如果不是今天,设为-1表示没有当前小时 + + // 触发柱体动画 + useEffect(() => { + if (chartData && chartData.length > 0) { + // 重置所有动画值 + animatedValues.forEach(animValue => animValue.setValue(0)); + + // 找出所有有饮水记录的柱体索引 + const activeBarIndices = chartData + .map((data, index) => ({ data, index })) + .filter(item => item.data.amount > 0) + .map(item => item.index); + + // 依次执行动画,每个柱体间隔100ms + activeBarIndices.forEach((barIndex, sequenceIndex) => { + setTimeout(() => { + Animated.spring(animatedValues[barIndex], { + toValue: 1, + tension: 150, + friction: 8, + useNativeDriver: false, + }).start(); + }, sequenceIndex * 100); // 每个柱体延迟100ms + }); + } + }, [chartData, animatedValues]); + + // 处理添加喝水 - 右上角按钮直接添加 + const handleQuickAddWater = async () => { + // 默认添加250ml水 + const waterAmount = 250; + // 如果有选中日期,则为该日期添加记录;否则为今天添加记录 + const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString(); + await addWaterRecord(waterAmount, recordedAt); + }; + + // 处理卡片点击 - 打开配置饮水弹窗 + const handleCardPress = () => { + setIsModalVisible(true); + }; + + // 处理关闭弹窗 + const handleCloseModal = () => { + setIsModalVisible(false); + }; + + return ( + <> + + {/* 标题和加号按钮 */} + + 喝水 + + + + + + + {/* 柱状图 */} + + + + {chartData.map((data, index) => { + // 判断是否是当前小时或者有活动的小时 + const isActive = data.amount > 0; + const isCurrent = isToday && index <= currentHour; + + // 动画变换:高度从0到目标高度 + const animatedHeight = animatedValues[index].interpolate({ + inputRange: [0, 1], + outputRange: [0, data.height], + }); + + // 动画变换:透明度从0到1(保持柱体在动画过程中可见) + const animatedOpacity = animatedValues[index].interpolate({ + inputRange: [0, 0.1, 1], + outputRange: [0, 1, 1], + }); + + return ( + + {/* 背景柱体 - 始终显示,使用蓝色系的淡色 */} + + + {/* 数据柱体 - 只有当有数据时才显示并执行动画 */} + {isActive && ( + + )} + + ); + })} + + + + + {/* 饮水量显示 */} + + + {currentIntake !== null ? `${currentIntake}ml` : '——'} + + + / {targetIntake}ml + + + + {/* 完成率显示 */} + {waterStats && ( + + + {Math.round(waterStats.completionRate)}% + + + )} + + + {/* 配置饮水弹窗 */} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'space-between', + borderRadius: 20, + padding: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.08, + shadowRadius: 20, + elevation: 8, + backgroundColor: '#FFFFFF', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + title: { + fontSize: 14, + color: '#192126', + fontWeight: '500', + }, + addButton: { + width: 22, + height: 22, + borderRadius: 16, + backgroundColor: '#E1E7FF', + alignItems: 'center', + justifyContent: 'center', + }, + addButtonText: { + fontSize: 14, + color: '#6366F1', + fontWeight: '700', + lineHeight: 14, + }, + chartContainer: { + flex: 1, + justifyContent: 'center', + }, + chartWrapper: { + width: '100%', + alignItems: 'center', + }, + chartArea: { + flexDirection: 'row', + alignItems: 'flex-end', + height: 20, + width: '100%', + maxWidth: 240, + justifyContent: 'space-between', + paddingHorizontal: 4, + }, + barContainer: { + width: 4, + height: 20, + alignItems: 'center', + justifyContent: 'flex-end', + position: 'relative', + }, + chartBar: { + width: 4, + borderRadius: 1, + position: 'absolute', + bottom: 0, + }, + statsContainer: { + flexDirection: 'row', + alignItems: 'baseline', + marginTop: 6, + }, + currentIntake: { + fontSize: 14, + fontWeight: '600', + color: '#192126', + }, + targetIntake: { + fontSize: 12, + color: '#6B7280', + marginLeft: 4, + }, + completionContainer: { + alignItems: 'flex-start', + marginTop: 2, + }, + completionText: { + fontSize: 12, + color: '#10B981', + fontWeight: '500', + }, +}); + +export default WaterIntakeCard; \ No newline at end of file diff --git a/contexts/ToastContext.tsx b/contexts/ToastContext.tsx index 84e59f6..0859497 100644 --- a/contexts/ToastContext.tsx +++ b/contexts/ToastContext.tsx @@ -1,4 +1,5 @@ import SuccessToast from '@/components/ui/SuccessToast'; +import { Colors } from '@/constants/Colors'; import { setToastRef } from '@/utils/toast.utils'; import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; @@ -43,7 +44,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { showToast({ message, duration, - backgroundColor: '#DF42D0', // 主题色 + backgroundColor: Colors.light.primary, // 主题色 icon: '✓', }); }; diff --git a/hooks/useWaterData.ts b/hooks/useWaterData.ts new file mode 100644 index 0000000..2a1a0a1 --- /dev/null +++ b/hooks/useWaterData.ts @@ -0,0 +1,494 @@ +import { CreateWaterRecordDto, UpdateWaterRecordDto, WaterRecordSource } from '@/services/waterRecords'; +import { AppDispatch, RootState } from '@/store'; +import { + createWaterRecordAction, + deleteWaterRecordAction, + fetchTodayWaterStats, + fetchWaterRecords, + fetchWaterRecordsByDateRange, + setSelectedDate, + updateWaterGoalAction, + updateWaterRecordAction, +} from '@/store/waterSlice'; +import { Toast } from '@/utils/toast.utils'; +import dayjs from 'dayjs'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +export const useWaterData = () => { + const dispatch = useDispatch(); + + // 选择器 + const todayStats = useSelector((state: RootState) => state.water.todayStats); + const dailyWaterGoal = useSelector((state: RootState) => state.water.dailyWaterGoal); + const waterRecords = useSelector((state: RootState) => + state.water.waterRecords[dayjs().format('YYYY-MM-DD')] || [] + ); + const waterRecordsMeta = useSelector((state: RootState) => + state.water.waterRecordsMeta[dayjs().format('YYYY-MM-DD')] || { + total: 0, + page: 1, + limit: 20, + hasMore: false + } + ); + const selectedDate = useSelector((state: RootState) => state.water.selectedDate); + const loading = useSelector((state: RootState) => state.water.loading); + const error = useSelector((state: RootState) => state.water.error); + + // 获取指定日期的记录(支持分页) + const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => { + await dispatch(fetchWaterRecords({ date, page, limit })); + }, [dispatch]); + + // 加载更多记录 + const loadMoreWaterRecords = useCallback(async () => { + const currentMeta = waterRecordsMeta; + if (currentMeta.hasMore && !loading.records) { + const nextPage = currentMeta.page + 1; + await dispatch(fetchWaterRecords({ + date: selectedDate, + page: nextPage, + limit: currentMeta.limit + })); + } + }, [dispatch, waterRecordsMeta, loading.records, selectedDate]); + + // 获取日期范围的记录 + const getWaterRecordsByDateRange = useCallback(async ( + startDate: string, + endDate: string, + page = 1, + limit = 20 + ) => { + await dispatch(fetchWaterRecordsByDateRange({ startDate, endDate, page, limit })); + }, [dispatch]); + + // 加载今日数据 + const loadTodayData = useCallback(() => { + dispatch(fetchTodayWaterStats()); + dispatch(fetchWaterRecords({ date: dayjs().format('YYYY-MM-DD') })); + }, [dispatch]); + + // 加载指定日期数据 + const loadDataByDate = useCallback((date: string) => { + dispatch(setSelectedDate(date)); + dispatch(fetchWaterRecords({ date })); + }, [dispatch]); + + // 创建喝水记录 + const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { + const dto: CreateWaterRecordDto = { + amount, + source: WaterRecordSource.Manual, + recordedAt, + }; + + try { + await dispatch(createWaterRecordAction(dto)).unwrap(); + // 重新获取今日统计 + dispatch(fetchTodayWaterStats()); + return true; + } catch (error: any) { + console.error('添加喝水记录失败:', error); + + // 根据错误类型显示不同的提示信息 + let errorMessage = '添加喝水记录失败'; + + if (error?.message) { + if (error.message.includes('网络')) { + errorMessage = '网络连接失败,请检查网络后重试'; + } else if (error.message.includes('参数')) { + errorMessage = '参数错误,请重新输入'; + } else if (error.message.includes('权限')) { + errorMessage = '权限不足,请重新登录'; + } else if (error.message.includes('服务器')) { + errorMessage = '服务器繁忙,请稍后重试'; + } else { + errorMessage = error.message; + } + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch]); + + // 更新喝水记录 + const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { + const dto: UpdateWaterRecordDto = { + id, + amount, + note, + recordedAt, + }; + + try { + await dispatch(updateWaterRecordAction(dto)).unwrap(); + // 重新获取今日统计 + dispatch(fetchTodayWaterStats()); + + return true; + } catch (error: any) { + console.error('更新喝水记录失败:', error); + + let errorMessage = '更新喝水记录失败'; + if (error?.message) { + errorMessage = error.message; + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch]); + + // 删除喝水记录 + const removeWaterRecord = useCallback(async (id: string) => { + try { + await dispatch(deleteWaterRecordAction(id)).unwrap(); + // 重新获取今日统计 + dispatch(fetchTodayWaterStats()); + + return true; + } catch (error: any) { + console.error('删除喝水记录失败:', error); + + let errorMessage = '删除喝水记录失败'; + if (error?.message) { + errorMessage = error.message; + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch]); + + // 更新喝水目标 + const updateWaterGoal = useCallback(async (goal: number) => { + try { + await dispatch(updateWaterGoalAction(goal)).unwrap(); + return true; + } catch (error: any) { + console.error('更新喝水目标失败:', error); + + let errorMessage = '更新喝水目标失败'; + if (error?.message) { + errorMessage = error.message; + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch]); + + // 计算总喝水量 + const getTotalAmount = useCallback((records: any[]) => { + return records.reduce((total, record) => total + record.amount, 0); + }, []); + + // 按小时分组数据 + const getHourlyData = useCallback((records: any[]) => { + const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + amount: 0, + })); + + records.forEach(record => { + // 优先使用 recordedAt,如果没有则使用 createdAt + const dateTime = record.recordedAt || record.createdAt; + const hour = dayjs(dateTime).hour(); + if (hour >= 0 && hour < 24) { + hourlyData[hour].amount += record.amount; + } + }); + + return hourlyData; + }, []); + + // 计算完成率(返回百分比) + const calculateCompletionRate = useCallback((totalAmount: number, goal: number) => { + if (goal <= 0) return 0; + return Math.min((totalAmount / goal) * 100, 100); + }, []); + + // 初始化加载 + useEffect(() => { + loadTodayData(); + }, [loadTodayData]); + + return { + // 数据 + todayStats, + dailyWaterGoal, + waterRecords, + waterRecordsMeta, + selectedDate, + loading, + error, + + // 方法 + loadTodayData, + loadDataByDate, + getWaterRecordsByDate, + loadMoreWaterRecords, + getWaterRecordsByDateRange, + addWaterRecord, + updateWaterRecord, + removeWaterRecord, + updateWaterGoal, + getTotalAmount, + getHourlyData, + calculateCompletionRate, + }; +}; + +// 简化的Hook,只返回今日数据 +export const useTodayWaterData = () => { + const { + todayStats, + dailyWaterGoal, + waterRecords, + waterRecordsMeta, + selectedDate, + loading, + error, + getWaterRecordsByDate, + loadMoreWaterRecords, + getWaterRecordsByDateRange, + addWaterRecord, + updateWaterRecord, + removeWaterRecord, + updateWaterGoal, + } = useWaterData(); + + // 获取今日记录(默认第一页) + const todayWaterRecords = useSelector((state: RootState) => + state.water.waterRecords[dayjs().format('YYYY-MM-DD')] || [] + ); + + const todayMeta = useSelector((state: RootState) => + state.water.waterRecordsMeta[dayjs().format('YYYY-MM-DD')] || { + total: 0, + page: 1, + limit: 20, + hasMore: false + } + ); + + // 获取今日记录(向后兼容) + const fetchTodayWaterRecords = useCallback(async (page = 1, limit = 20) => { + const today = dayjs().format('YYYY-MM-DD'); + await getWaterRecordsByDate(today, page, limit); + }, [getWaterRecordsByDate]); + + return { + todayStats, + dailyWaterGoal, + waterRecords: todayWaterRecords, + waterRecordsMeta: todayMeta, + selectedDate, + loading, + error, + fetchTodayWaterRecords, + loadMoreWaterRecords, + getWaterRecordsByDateRange, + addWaterRecord, + updateWaterRecord, + removeWaterRecord, + updateWaterGoal, + }; +}; + +// 新增:按日期获取饮水数据的 hook +export const useWaterDataByDate = (targetDate?: string) => { + const dispatch = useDispatch(); + + // 如果没有传入日期,默认使用今天 + const dateToUse = targetDate || dayjs().format('YYYY-MM-DD'); + + // 选择器 - 获取指定日期的数据 + const dailyWaterGoal = useSelector((state: RootState) => state.water.dailyWaterGoal) || 0; + const waterRecords = useSelector((state: RootState) => + state.water.waterRecords[dateToUse] || [] + ); + const waterRecordsMeta = useSelector((state: RootState) => + state.water.waterRecordsMeta[dateToUse] || { + total: 0, + page: 1, + limit: 20, + hasMore: false + } + ); + const loading = useSelector((state: RootState) => state.water.loading); + const error = useSelector((state: RootState) => state.water.error); + + // 计算指定日期的统计数据 + const waterStats = useMemo(() => { + if (!waterRecords || waterRecords.length === 0) { + return { + totalAmount: 0, + completionRate: 0, + recordCount: 0 + }; + } + + const totalAmount = waterRecords.reduce((total, record) => total + record.amount, 0); + const completionRate = dailyWaterGoal > 0 ? Math.min((totalAmount / dailyWaterGoal) * 100, 100) : 0; + + return { + totalAmount, + completionRate, + recordCount: waterRecords.length + }; + }, [waterRecords, dailyWaterGoal]); + + // 获取指定日期的记录 + const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => { + await dispatch(fetchWaterRecords({ date, page, limit })); + }, [dispatch]); + + // 创建喝水记录 + const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { + const dto: CreateWaterRecordDto = { + amount, + source: WaterRecordSource.Manual, + recordedAt: recordedAt || dayjs().toISOString(), + }; + + try { + await dispatch(createWaterRecordAction(dto)).unwrap(); + + // 重新获取当前日期的数据 + await getWaterRecordsByDate(dateToUse); + + // 如果是今天的数据,也更新今日统计 + if (dateToUse === dayjs().format('YYYY-MM-DD')) { + dispatch(fetchTodayWaterStats()); + } + + return true; + } catch (error: any) { + console.error('添加喝水记录失败:', error); + + // 根据错误类型显示不同的提示信息 + let errorMessage = '添加喝水记录失败'; + + if (error?.message) { + if (error.message.includes('网络')) { + errorMessage = '网络连接失败,请检查网络后重试'; + } else if (error.message.includes('参数')) { + errorMessage = '参数错误,请重新输入'; + } else if (error.message.includes('权限')) { + errorMessage = '权限不足,请重新登录'; + } else if (error.message.includes('服务器')) { + errorMessage = '服务器繁忙,请稍后重试'; + } else { + errorMessage = error.message; + } + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch, dateToUse, getWaterRecordsByDate]); + + // 更新喝水记录 + const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { + const dto: UpdateWaterRecordDto = { + id, + amount, + note, + recordedAt, + }; + + try { + await dispatch(updateWaterRecordAction(dto)).unwrap(); + + // 重新获取当前日期的数据 + await getWaterRecordsByDate(dateToUse); + + // 如果是今天的数据,也更新今日统计 + if (dateToUse === dayjs().format('YYYY-MM-DD')) { + dispatch(fetchTodayWaterStats()); + } + + return true; + } catch (error: any) { + console.error('更新喝水记录失败:', error); + + let errorMessage = '更新喝水记录失败'; + if (error?.message) { + errorMessage = error.message; + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch, dateToUse, getWaterRecordsByDate]); + + // 删除喝水记录 + const removeWaterRecord = useCallback(async (id: string) => { + try { + await dispatch(deleteWaterRecordAction(id)).unwrap(); + + // 重新获取当前日期的数据 + await getWaterRecordsByDate(dateToUse); + + // 如果是今天的数据,也更新今日统计 + if (dateToUse === dayjs().format('YYYY-MM-DD')) { + dispatch(fetchTodayWaterStats()); + } + + return true; + } catch (error: any) { + console.error('删除喝水记录失败:', error); + + let errorMessage = '删除喝水记录失败'; + if (error?.message) { + errorMessage = error.message; + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch, dateToUse, getWaterRecordsByDate]); + + // 更新喝水目标 + const updateWaterGoal = useCallback(async (goal: number) => { + try { + await dispatch(updateWaterGoalAction(goal)).unwrap(); + return true; + } catch (error: any) { + console.error('更新喝水目标失败:', error); + + let errorMessage = '更新喝水目标失败'; + if (error?.message) { + errorMessage = error.message; + } + + Toast.error(errorMessage); + return false; + } + }, [dispatch]); + + // 初始化加载指定日期的数据 + useEffect(() => { + if (dateToUse) { + getWaterRecordsByDate(dateToUse); + } + }, [dateToUse, getWaterRecordsByDate]); + + return { + waterStats, + dailyWaterGoal, + waterRecords, + waterRecordsMeta, + loading, + error, + addWaterRecord, + updateWaterRecord, + removeWaterRecord, + updateWaterGoal, + getWaterRecordsByDate, + }; +}; \ No newline at end of file diff --git a/services/api.ts b/services/api.ts index 5a8c2b0..023a346 100644 --- a/services/api.ts +++ b/services/api.ts @@ -9,8 +9,8 @@ export async function setAuthToken(token: string | null): Promise { inMemoryToken = token; } -export function getAuthToken(): string | null { - return inMemoryToken; +export function getAuthToken(): Promise { + return AsyncStorage.getItem(STORAGE_KEYS.authToken); } export type ApiRequestOptions = { @@ -31,7 +31,7 @@ async function doFetch(path: string, options: ApiRequestOptions = {}): Promis ...(options.headers || {}), }; - const token = getAuthToken(); + const token = await getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } @@ -77,14 +77,6 @@ export const STORAGE_KEYS = { privacyAgreed: '@privacy_agreed', } as const; -export async function loadPersistedToken(): Promise { - try { - const t = await AsyncStorage.getItem(STORAGE_KEYS.authToken); - return t || null; - } catch { - return null; - } -} // 流式文本 POST(基于 XMLHttpRequest),支持增量 onChunk 回调与取消 export type TextStreamCallbacks = { @@ -99,9 +91,9 @@ export type TextStreamOptions = { signal?: AbortSignal; }; -export function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) { +export async function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) { const url = buildApiUrl(path); - const token = getAuthToken(); + const token = await getAuthToken(); // 生成请求ID用于追踪和取消 const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -128,11 +120,6 @@ export function postTextStream(path: string, body: any, callbacks: TextStreamCal resolved = true; }; - // 日志:请求开始 - try { - console.log('[AI_CHAT][stream] start', { url, hasToken: !!token, body }); - } catch { } - xhr.open('POST', url, true); // 设置超时(可选) if (typeof options.timeoutMs === 'number') { diff --git a/services/notifications.ts b/services/notifications.ts index e535fd3..a1715d9 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -100,8 +100,7 @@ export class NotificationService { this.setupNotificationListeners(); // 检查已存在的通知 - const existingNotifications = await this.getAllScheduledNotifications(); - console.log('已存在的通知数量:', existingNotifications.length); + await this.getAllScheduledNotifications(); this.isInitialized = true; console.log('推送通知服务初始化成功'); diff --git a/services/waterRecords.ts b/services/waterRecords.ts new file mode 100644 index 0000000..2691d9d --- /dev/null +++ b/services/waterRecords.ts @@ -0,0 +1,167 @@ +import { api } from './api'; + +// 喝水记录类型 +export interface WaterRecord { + id: string; + userId?: string; + amount: number; // 喝水量(毫升) + source?: 'Manual' | 'Auto'; // 记录来源 + note?: string; // 备注 + recordedAt: string; // 记录时间 ISO格式 + createdAt: string; // 创建时间 ISO格式 + updatedAt: string; // 更新时间 ISO格式 +} + +export enum WaterRecordSource { + Manual = 'manual', + Auto = 'auto', + Other = 'other', +} + +// 创建喝水记录请求 +export interface CreateWaterRecordDto { + amount: number; // 喝水量(毫升) + recordedAt?: string; // 记录时间,默认为当前时间 + source?: WaterRecordSource; // 记录来源,默认为 'manual' +} + +// 更新喝水记录请求 +export interface UpdateWaterRecordDto { + id: string; + amount?: number; // 喝水量(毫升) + recordedAt?: string; // 记录时间 + source?: 'Manual' | 'Auto'; // 记录来源 + note?: string; // 备注 +} + +// 删除喝水记录请求 +export interface DeleteWaterRecordDto { + id: string; +} + +// 今日喝水统计 +export interface TodayWaterStats { + date: string; // 统计日期 + totalAmount: number; // 当日总喝水量 + dailyGoal: number; // 每日目标 + completionRate: number; // 完成率(百分比) + recordCount: number; // 记录次数 + records?: WaterRecord[]; // 当日所有记录(可选) +} + +// 更新喝水目标请求 +export interface UpdateWaterGoalDto { + dailyWaterGoal: number; // 每日喝水目标(毫升) +} + +// 创建喝水记录 +export async function createWaterRecord(dto: CreateWaterRecordDto): Promise { + return await api.post('/water-records', dto); +} + +// 获取喝水记录列表 +export async function getWaterRecords(params?: { + startDate?: string; // 开始日期 (YYYY-MM-DD) + endDate?: string; // 结束日期 (YYYY-MM-DD) + page?: number; // 页码,默认1 + limit?: number; // 每页数量,默认20 + date?: string; // 指定日期,格式:YYYY-MM-DD (向后兼容) +}): Promise<{ + records: WaterRecord[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +}> { + const queryParams = new URLSearchParams(); + + // 处理日期范围查询 + if (params?.startDate) queryParams.append('startDate', params.startDate); + if (params?.endDate) queryParams.append('endDate', params.endDate); + + // 处理单日期查询(向后兼容) + if (params?.date) queryParams.append('startDate', params.date); + if (params?.date) queryParams.append('endDate', params.date); + + // 处理分页 + const page = params?.page || 1; + const limit = params?.limit || 20; + queryParams.append('page', page.toString()); + queryParams.append('limit', limit.toString()); + + const path = `/water-records${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await api.get<{ + records: WaterRecord[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + }>(path); + + const pagination = response.pagination || { page, limit, total: 0, totalPages: 0 }; + return { + records: response.records || [], + total: pagination.total, + page: pagination.page, + limit: pagination.limit, + hasMore: pagination.page < pagination.totalPages + }; +} + +// 更新喝水记录 +export async function updateWaterRecord(dto: UpdateWaterRecordDto): Promise { + const { id, ...updateData } = dto; + return await api.put(`/water-records/${id}`, updateData); +} + +// 删除喝水记录 +export async function deleteWaterRecord(id: string): Promise { + return await api.delete(`/water-records/${id}`); +} + +// 更新喝水目标 +export async function updateWaterGoal(dto: UpdateWaterGoalDto): Promise<{ dailyWaterGoal: number }> { + return await api.put('/water-records/goal/daily', dto); +} + +// 获取今日喝水统计 +export async function getTodayWaterStats(): Promise { + return await api.get('/water-records/stats'); +} + +// 获取指定日期的喝水统计 +export async function getWaterStatsByDate(date: string): Promise { + return await api.get(`/water-records/stats?date=${date}`); +} + +// 按小时分组获取喝水记录(用于图表显示) +export function groupWaterRecordsByHour(records: WaterRecord[]): { hour: number; amount: number }[] { + const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + amount: 0, + })); + + records.forEach(record => { + // 优先使用 recordedAt,如果没有则使用 createdAt + const dateTime = record.recordedAt || record.createdAt; + const hour = new Date(dateTime).getHours(); + if (hour >= 0 && hour < 24) { + hourlyData[hour].amount += record.amount; + } + }); + + return hourlyData; +} + +// 获取指定日期的总喝水量 +export function getTotalWaterAmount(records: WaterRecord[]): number { + return records.reduce((total, record) => total + record.amount, 0); +} + +// 计算喝水目标完成率 +export function calculateCompletionRate(totalAmount: number, dailyGoal: number): number { + if (dailyGoal <= 0) return 0; + return Math.min(totalAmount / dailyGoal, 1); +} \ No newline at end of file diff --git a/store/index.ts b/store/index.ts index bb67ed3..b4cb839 100644 --- a/store/index.ts +++ b/store/index.ts @@ -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), diff --git a/store/userSlice.ts b/store/userSlice.ts index 00438ab..3a26abe 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -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()) { diff --git a/store/waterSlice.ts b/store/waterSlice.ts new file mode 100644 index 0000000..78e1b2b --- /dev/null +++ b/store/waterSlice.ts @@ -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; + // 分页元数据 + waterRecordsMeta: Record; + // 今日喝水统计 + 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) => { + 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) => { + 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) => { + 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) => { + 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; \ No newline at end of file diff --git a/test-water-api.md b/test-water-api.md new file mode 100644 index 0000000..33ffdb5 --- /dev/null +++ b/test-water-api.md @@ -0,0 +1,135 @@ +# 喝水记录 API 修复测试文档 + +## 修复内容总结 + +### 1. 服务层修复 (services/waterRecords.ts) + +#### 接口路径修复 +- ✅ 更新喝水目标:`/water-goal` → `/water-records/goal/daily` +- ✅ 获取统计数据:`/water-stats/today` → `/water-records/stats` +- ✅ 获取指定日期统计:`/water-stats/${date}` → `/water-records/stats?date=${date}` + +#### 数据结构修复 +- ✅ 字段名称:`remark` → `note` +- ✅ 枚举值:`'manual' | 'auto' | 'other'` → `'Manual' | 'Auto'` +- ✅ 新增字段:`recordedAt` (记录时间) +- ✅ 响应结构:处理标准 API 响应格式 `{ data: {...}, pagination: {...} }` + +#### 类型定义更新 +```typescript +// 旧版本 +interface WaterRecord { + source: 'manual' | 'auto' | 'other'; + remark?: string; +} + +// 新版本 +interface WaterRecord { + source?: 'Manual' | 'Auto'; + note?: string; + recordedAt: string; +} +``` + +### 2. Redux Store 修复 (store/waterSlice.ts) + +#### Loading 状态完善 +- ✅ 新增:`create`, `update`, `delete` loading 状态 + +#### 完成率计算修复 +- ✅ 统一使用百分比格式:`(totalAmount / dailyGoal) * 100` +- ✅ 所有相关计算都已更新 + +#### 日期字段处理 +- ✅ 优先使用 `recordedAt`,回退到 `createdAt` + +### 3. Hooks 修复 (hooks/useWaterData.ts) + +#### 函数签名更新 +```typescript +// 旧版本 +addWaterRecord(amount: number, remark?: string) + +// 新版本 +addWaterRecord(amount: number, note?: string, recordedAt?: string) +``` + +#### 完成率计算 +- ✅ 返回百分比格式而非小数 + +### 4. 组件修复 + +#### WaterIntakeCard.tsx +- ✅ 日期字段:优先使用 `recordedAt` +- ✅ 完成率显示:移除多余的 `* 100` 计算 + +#### AddWaterModal.tsx +- ✅ 字段名称:`remark` → `note` +- ✅ 数据结构:添加 `source: 'Manual'` + +## 测试要点 + +### 1. API 调用测试 +```javascript +// 测试创建记录 +const createResult = await createWaterRecord({ + amount: 250, + note: "测试记录", + source: "Manual", + recordedAt: "2023-12-01T10:00:00.000Z" +}); + +// 测试获取统计 +const stats = await getTodayWaterStats(); +console.log('完成率应该是百分比:', stats.completionRate); // 应该是 0-100 的数值 + +// 测试更新目标 +const goalResult = await updateWaterGoal({ dailyWaterGoal: 2500 }); +``` + +### 2. Redux 状态测试 +```javascript +// 测试完成率计算 +// 假设总量 1500ml,目标 2000ml +// 期望完成率:75 (百分比) +const expectedRate = (1500 / 2000) * 100; // 75 +``` + +### 3. 组件渲染测试 +- ✅ 完成率显示正确(不会超过 100%) +- ✅ 图表数据使用正确的时间字段 +- ✅ 表单提交使用正确的字段名称 + +## 兼容性说明 + +### 向后兼容 +- ✅ 保留了 `createdAt` 字段的回退逻辑 +- ✅ 保留了单日期查询的兼容性处理 +- ✅ 保留了原有的选择器函数 + +### 新功能支持 +- ✅ 支持自定义记录时间 (`recordedAt`) +- ✅ 支持新的 API 响应格式 +- ✅ 支持百分比格式的完成率 + +## 需要验证的功能 + +1. **创建记录**:确保新记录包含正确的字段 +2. **更新记录**:确保更新时使用正确的字段名 +3. **删除记录**:确保删除后统计数据正确更新 +4. **目标设置**:确保目标更新后完成率重新计算 +5. **统计查询**:确保返回正确的百分比格式完成率 +6. **图表显示**:确保使用正确的时间字段进行分组 + +## 潜在问题 + +1. **时区处理**:`recordedAt` 字段的时区处理需要注意 +2. **数据迁移**:现有数据可能没有 `recordedAt` 字段 +3. **API 兼容性**:确保后端 API 已经更新到新版本 + +## 建议测试流程 + +1. 单元测试:测试各个函数的输入输出 +2. 集成测试:测试 Redux 状态管理 +3. 端到端测试:测试完整的用户操作流程 +4. API 测试:使用 Postman 或类似工具测试 API 接口 \ No newline at end of file diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index eb7d1ab..5d53a21 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -319,8 +319,6 @@ export class NutritionNotificationHelpers { // 检查是否已经存在午餐提醒 const existingNotifications = await notificationService.getAllScheduledNotifications(); - - console.log('existingNotifications', existingNotifications); const existingLunchReminder = existingNotifications.find( notification => notification.content.data?.type === 'lunch_reminder' &&