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' &&