feat: 支持饮水记录卡片

This commit is contained in:
richarjiang
2025-09-02 15:50:35 +08:00
parent ed694f6142
commit 85a3c742df
16 changed files with 2066 additions and 56 deletions

View File

@@ -889,7 +889,7 @@ export default function CoachScreen() {
}; };
try { 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; streamAbortRef.current = controller;
} catch (e) { } catch (e) {
onError(e); onError(e);

View File

@@ -7,6 +7,7 @@ import HeartRateCard from '@/components/statistic/HeartRateCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import StepsCard from '@/components/StepsCard'; import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter'; import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard';
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard'; import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
@@ -18,7 +19,7 @@ import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; 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 { getTestHealthData } from '@/utils/mockHealthData';
import { calculateNutritionGoals } from '@/utils/nutrition'; import { calculateNutritionGoals } from '@/utils/nutrition';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -454,6 +455,15 @@ export default function ExploreScreen() {
style={styles.stepsCardOverride} style={styles.stepsCardOverride}
/> />
</FloatingCard> </FloatingCard>
{/* 饮水记录卡片 */}
<FloatingCard style={styles.masonryCard} delay={500}>
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
</FloatingCard>
<FloatingCard style={styles.masonryCard} delay={0}> <FloatingCard style={styles.masonryCard} delay={0}>
<StressMeter <StressMeter
value={hrvValue} value={hrvValue}
@@ -810,6 +820,11 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
height: '100%', // 填充整个masonryCard height: '100%', // 填充整个masonryCard
}, },
waterCardOverride: {
margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16,
height: '100%', // 填充整个masonryCard
},
compactStepsCard: { compactStepsCard: {
minHeight: 100, minHeight: 100,
}, },

View File

@@ -0,0 +1,377 @@
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import React, { useState } from 'react';
import {
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
interface AddWaterModalProps {
visible: boolean;
onClose: () => void;
selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD
}
interface TabButtonProps {
title: string;
isActive: boolean;
onPress: () => void;
}
const TabButton: React.FC<TabButtonProps> = ({ title, isActive, onPress }) => (
<TouchableOpacity
style={[styles.tabButton, isActive && styles.activeTabButton]}
onPress={onPress}
>
<Text style={[styles.tabButtonText, isActive && styles.activeTabButtonText]}>
{title}
</Text>
</TouchableOpacity>
);
const AddWaterModal: React.FC<AddWaterModalProps> = ({ visible, onClose, selectedDate }) => {
const [activeTab, setActiveTab] = useState<'add' | 'goal'>('add');
const [waterAmount, setWaterAmount] = useState<string>('250');
const [note, setNote] = useState<string>('');
const [dailyGoal, setDailyGoal] = useState<string>('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 = () => (
<View style={styles.tabContent}>
<Text style={styles.sectionTitle}> (ml)</Text>
<TextInput
style={styles.input}
value={waterAmount}
onChangeText={setWaterAmount}
keyboardType="numeric"
placeholder="请输入饮水量"
placeholderTextColor="#999"
/>
<Text style={styles.sectionTitle}></Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.quickAmountsContainer}
>
<View style={styles.quickAmountsWrapper}>
{quickAmounts.map((amount) => (
<TouchableOpacity
key={amount}
style={[
styles.quickAmountButton,
parseInt(waterAmount) === amount && styles.quickAmountButtonActive
]}
onPress={() => setWaterAmount(amount.toString())}
>
<Text style={[
styles.quickAmountText,
parseInt(waterAmount) === amount && styles.quickAmountTextActive
]}>
{amount}ml
</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
<Text style={styles.sectionTitle}> ()</Text>
<TextInput
style={[styles.input, styles.remarkInput]}
value={note}
onChangeText={setNote}
placeholder="添加备注..."
placeholderTextColor="#999"
multiline
/>
<View style={styles.buttonContainer}>
<TouchableOpacity style={[styles.button, styles.cancelButton]} onPress={onClose}>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.button, styles.confirmButton]} onPress={handleAddWater}>
<Text style={styles.confirmButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
const renderGoalTab = () => (
<View style={styles.tabContent}>
<Text style={styles.sectionTitle}> (ml)</Text>
<TextInput
style={styles.input}
value={dailyGoal}
onChangeText={setDailyGoal}
keyboardType="numeric"
placeholder="请输入每日饮水目标"
placeholderTextColor="#999"
/>
<Text style={styles.sectionTitle}></Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.quickAmountsContainer}
>
<View style={styles.quickAmountsWrapper}>
{goalPresets.map((goal) => (
<TouchableOpacity
key={goal}
style={[
styles.quickAmountButton,
parseInt(dailyGoal) === goal && styles.quickAmountButtonActive
]}
onPress={() => setDailyGoal(goal.toString())}
>
<Text style={[
styles.quickAmountText,
parseInt(dailyGoal) === goal && styles.quickAmountTextActive
]}>
{goal}ml
</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
<View style={styles.buttonContainer}>
<TouchableOpacity style={[styles.button, styles.cancelButton]} onPress={onClose}>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.button, styles.confirmButton]} onPress={handleUpdateGoal}>
<Text style={styles.confirmButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
return (
<Modal
animationType="slide"
transparent={true}
visible={visible}
onRequestClose={onClose}
>
<KeyboardAvoidingView
style={styles.centeredView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.modalView}>
<View style={styles.header}>
<Text style={styles.modalTitle}></Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<View style={styles.tabContainer}>
<TabButton
title="添加记录"
isActive={activeTab === 'add'}
onPress={() => setActiveTab('add')}
/>
<TabButton
title="设置目标"
isActive={activeTab === 'goal'}
onPress={() => setActiveTab('goal')}
/>
</View>
<ScrollView style={styles.contentScrollView}>
{activeTab === 'add' ? renderAddRecordTab() : renderGoalTab()}
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
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;

View File

@@ -12,36 +12,71 @@ type AnimatedNumberProps = {
export function AnimatedNumber({ export function AnimatedNumber({
value, value,
durationMs = 800, durationMs = 300,
format, format,
style, style,
resetToken, resetToken,
}: AnimatedNumberProps) { }: AnimatedNumberProps) {
const animated = useRef(new Animated.Value(0)).current; const opacity = useRef(new Animated.Value(1)).current;
const [display, setDisplay] = useState<string>('0'); const [display, setDisplay] = useState<string>('0');
const [currentValue, setCurrentValue] = useState(0);
useEffect(() => { useEffect(() => {
animated.stopAnimation(() => { // 如果值没有变化,不执行动画
animated.setValue(0); if (value === currentValue && resetToken === undefined) {
Animated.timing(animated, { return;
toValue: value, }
duration: durationMs,
easing: Easing.out(Easing.cubic), // 停止当前动画
opacity.stopAnimation(() => {
// 创建优雅的透明度变化动画
const fadeOut = Animated.timing(opacity, {
toValue: 0.2, // 淡出到较低透明度
duration: durationMs * 0.4, // 淡出占总时长的40%
easing: Easing.out(Easing.quad),
useNativeDriver: false, 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, resetToken]); }, [value, resetToken]);
// 初始化显示值
useEffect(() => { useEffect(() => {
const id = animated.addListener(({ value: v }) => { if (currentValue !== value) {
const num = Number(v) || 0; setCurrentValue(value);
setDisplay(format ? format(num) : `${Math.round(num)}`); setDisplay(format ? format(value) : `${Math.round(value)}`);
}); }
return () => animated.removeListener(id); }, [value, format, currentValue]);
}, [animated, format]);
return <Animated.Text style={style}>{display}</Animated.Text>; return (
<Animated.Text
style={[
style,
{
opacity: opacity
}
]}
>
{display}
</Animated.Text>
);
} }

View File

@@ -1,13 +1,14 @@
import React, { useMemo, useRef, useEffect } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { import {
Animated,
StyleSheet, StyleSheet,
Text, Text,
View, View,
ViewStyle, ViewStyle
Animated
} from 'react-native'; } from 'react-native';
import { HourlyStepData } from '@/utils/health'; import { HourlyStepData } from '@/utils/health';
import { AnimatedNumber } from './AnimatedNumber';
// 使用原生View来替代SVG避免导入问题 // 使用原生View来替代SVG避免导入问题
// import Svg, { Rect } from 'react-native-svg'; // import Svg, { Rect } from 'react-native-svg';
@@ -132,9 +133,12 @@ const StepsCard: React.FC<StepsCardProps> = ({
{/* 步数和目标显示 */} {/* 步数和目标显示 */}
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<Text style={styles.stepCount}> <AnimatedNumber
{stepCount !== null ? stepCount.toLocaleString() : '——'} value={stepCount || 0}
</Text> style={styles.stepCount}
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
resetToken={stepCount}
/>
</View> </View>
</View> </View>
); );

View File

@@ -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<WaterIntakeCardProps> = ({
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 (
<>
<TouchableOpacity
style={[styles.container, style]}
onPress={handleCardPress}
activeOpacity={0.8}
>
{/* 标题和加号按钮 */}
<View style={styles.header}>
<Text style={styles.title}></Text>
<TouchableOpacity style={styles.addButton} onPress={handleQuickAddWater}>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{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 (
<View key={`bar-container-${index}`} style={styles.barContainer}>
{/* 背景柱体 - 始终显示,使用蓝色系的淡色 */}
<View
style={[
styles.chartBar,
{
height: 20, // 背景柱体占满整个高度
backgroundColor: '#F0F9FF', // 蓝色系淡色
}
]}
/>
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
{isActive && (
<Animated.View
style={[
styles.chartBar,
{
height: animatedHeight,
backgroundColor: '#7DD3FC', // 蓝色系
opacity: animatedOpacity,
}
]}
/>
)}
</View>
);
})}
</View>
</View>
</View>
{/* 饮水量显示 */}
<View style={styles.statsContainer}>
<Text style={styles.currentIntake}>
{currentIntake !== null ? `${currentIntake}ml` : '——'}
</Text>
<Text style={styles.targetIntake}>
/ {targetIntake}ml
</Text>
</View>
{/* 完成率显示 */}
{waterStats && (
<View style={styles.completionContainer}>
<Text style={styles.completionText}>
{Math.round(waterStats.completionRate)}%
</Text>
</View>
)}
</TouchableOpacity>
{/* 配置饮水弹窗 */}
<AddWaterModal
visible={isModalVisible}
onClose={handleCloseModal}
selectedDate={selectedDate}
/>
</>
);
};
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;

View File

@@ -1,4 +1,5 @@
import SuccessToast from '@/components/ui/SuccessToast'; import SuccessToast from '@/components/ui/SuccessToast';
import { Colors } from '@/constants/Colors';
import { setToastRef } from '@/utils/toast.utils'; import { setToastRef } from '@/utils/toast.utils';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
@@ -43,7 +44,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
showToast({ showToast({
message, message,
duration, duration,
backgroundColor: '#DF42D0', // 主题色 backgroundColor: Colors.light.primary, // 主题色
icon: '✓', icon: '✓',
}); });
}; };

494
hooks/useWaterData.ts Normal file
View File

@@ -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<AppDispatch>();
// 选择器
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<AppDispatch>();
// 如果没有传入日期,默认使用今天
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,
};
};

View File

@@ -9,8 +9,8 @@ export async function setAuthToken(token: string | null): Promise<void> {
inMemoryToken = token; inMemoryToken = token;
} }
export function getAuthToken(): string | null { export function getAuthToken(): Promise<string | null> {
return inMemoryToken; return AsyncStorage.getItem(STORAGE_KEYS.authToken);
} }
export type ApiRequestOptions = { export type ApiRequestOptions = {
@@ -31,7 +31,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
...(options.headers || {}), ...(options.headers || {}),
}; };
const token = getAuthToken(); const token = await getAuthToken();
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
@@ -77,14 +77,6 @@ export const STORAGE_KEYS = {
privacyAgreed: '@privacy_agreed', privacyAgreed: '@privacy_agreed',
} as const; } as const;
export async function loadPersistedToken(): Promise<string | null> {
try {
const t = await AsyncStorage.getItem(STORAGE_KEYS.authToken);
return t || null;
} catch {
return null;
}
}
// 流式文本 POST基于 XMLHttpRequest支持增量 onChunk 回调与取消 // 流式文本 POST基于 XMLHttpRequest支持增量 onChunk 回调与取消
export type TextStreamCallbacks = { export type TextStreamCallbacks = {
@@ -99,9 +91,9 @@ export type TextStreamOptions = {
signal?: AbortSignal; 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 url = buildApiUrl(path);
const token = getAuthToken(); const token = await getAuthToken();
// 生成请求ID用于追踪和取消 // 生成请求ID用于追踪和取消
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 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; resolved = true;
}; };
// 日志:请求开始
try {
console.log('[AI_CHAT][stream] start', { url, hasToken: !!token, body });
} catch { }
xhr.open('POST', url, true); xhr.open('POST', url, true);
// 设置超时(可选) // 设置超时(可选)
if (typeof options.timeoutMs === 'number') { if (typeof options.timeoutMs === 'number') {

View File

@@ -100,8 +100,7 @@ export class NotificationService {
this.setupNotificationListeners(); this.setupNotificationListeners();
// 检查已存在的通知 // 检查已存在的通知
const existingNotifications = await this.getAllScheduledNotifications(); await this.getAllScheduledNotifications();
console.log('已存在的通知数量:', existingNotifications.length);
this.isInitialized = true; this.isInitialized = true;
console.log('推送通知服务初始化成功'); console.log('推送通知服务初始化成功');

167
services/waterRecords.ts Normal file
View File

@@ -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<WaterRecord> {
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<WaterRecord> {
const { id, ...updateData } = dto;
return await api.put(`/water-records/${id}`, updateData);
}
// 删除喝水记录
export async function deleteWaterRecord(id: string): Promise<boolean> {
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<TodayWaterStats> {
return await api.get('/water-records/stats');
}
// 获取指定日期的喝水统计
export async function getWaterStatsByDate(date: string): Promise<TodayWaterStats> {
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);
}

View File

@@ -11,6 +11,7 @@ import scheduleExerciseReducer from './scheduleExerciseSlice';
import tasksReducer from './tasksSlice'; import tasksReducer from './tasksSlice';
import trainingPlanReducer from './trainingPlanSlice'; import trainingPlanReducer from './trainingPlanSlice';
import userReducer from './userSlice'; import userReducer from './userSlice';
import waterReducer from './waterSlice';
import workoutReducer from './workoutSlice'; import workoutReducer from './workoutSlice';
// 创建监听器中间件来处理自动同步 // 创建监听器中间件来处理自动同步
@@ -56,6 +57,7 @@ export const store = configureStore({
exerciseLibrary: exerciseLibraryReducer, exerciseLibrary: exerciseLibraryReducer,
foodLibrary: foodLibraryReducer, foodLibrary: foodLibraryReducer,
workout: workoutReducer, workout: workoutReducer,
water: waterReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware), getDefaultMiddleware().prepend(listenerMiddleware.middleware),

View File

@@ -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 { updateUser, UpdateUserDto } from '@/services/users';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
@@ -133,18 +133,17 @@ export const login = createAsyncThunk(
); );
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => { export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
const [token, profileStr, privacyAgreedStr] = await Promise.all([ const [profileStr, privacyAgreedStr] = await Promise.all([
loadPersistedToken(),
AsyncStorage.getItem(STORAGE_KEYS.userProfile), AsyncStorage.getItem(STORAGE_KEYS.userProfile),
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed), AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
]); ]);
await setAuthToken(token);
let profile: UserProfile = {}; let profile: UserProfile = {};
if (profileStr) { if (profileStr) {
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; } try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
} }
const privacyAgreed = privacyAgreedStr === 'true'; 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 () => { 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 }) => { export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', async (_, { rejectWithValue }) => {
try { try {
const data: WeightHistoryItem[] = await api.get('/api/users/weight-history'); const data: WeightHistoryItem[] = await api.get('/api/users/weight-history');
console.log('fetchWeightHistory', data);
return data; return data;
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err?.message ?? '获取用户体重历史记录失败'); return rejectWithValue(err?.message ?? '获取用户体重历史记录失败');
@@ -272,7 +270,6 @@ const userSlice = createSlice({
state.error = (action.payload as string) ?? '登录失败'; state.error = (action.payload as string) ?? '登录失败';
}) })
.addCase(rehydrateUser.fulfilled, (state, action) => { .addCase(rehydrateUser.fulfilled, (state, action) => {
state.token = action.payload.token;
state.profile = action.payload.profile; state.profile = action.payload.profile;
state.privacyAgreed = action.payload.privacyAgreed; state.privacyAgreed = action.payload.privacyAgreed;
if (!state.profile?.name || !state.profile.name.trim()) { if (!state.profile?.name || !state.profile.name.trim()) {

487
store/waterSlice.ts Normal file
View File

@@ -0,0 +1,487 @@
import {
createWaterRecord,
CreateWaterRecordDto,
deleteWaterRecord,
getTodayWaterStats,
getWaterRecords,
TodayWaterStats,
updateWaterGoal,
updateWaterRecord,
UpdateWaterRecordDto,
WaterRecord,
} from '@/services/waterRecords';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import dayjs from 'dayjs';
import { RootState } from './index';
// 状态接口
interface WaterState {
// 按日期存储的喝水记录
waterRecords: Record<string, WaterRecord[]>;
// 分页元数据
waterRecordsMeta: Record<string, {
total: number;
page: number;
limit: number;
hasMore: boolean;
}>;
// 今日喝水统计
todayStats: TodayWaterStats | null;
// 每日喝水目标
dailyWaterGoal: number | null;
// 当前选中的日期
selectedDate: string;
// 加载状态
loading: {
records: boolean;
stats: boolean;
goal: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
// 错误信息
error: string | null;
}
// 初始状态
const initialState: WaterState = {
waterRecords: {},
waterRecordsMeta: {},
todayStats: null,
dailyWaterGoal: null,
selectedDate: dayjs().format('YYYY-MM-DD'),
loading: {
records: false,
stats: false,
goal: false,
create: false,
update: false,
delete: false,
},
error: null,
};
// 异步 actions
// 获取指定日期的喝水记录
export const fetchWaterRecords = createAsyncThunk(
'water/fetchWaterRecords',
async ({ date, page = 1, limit = 20 }: { date: string; page?: number; limit?: number }) => {
const response = await getWaterRecords({
date,
page,
limit
});
return {
date,
records: response.records,
total: response.total,
page: response.page,
limit: response.limit,
hasMore: response.hasMore
};
}
);
// 获取指定日期范围的喝水记录
export const fetchWaterRecordsByDateRange = createAsyncThunk(
'water/fetchWaterRecordsByDateRange',
async ({ startDate, endDate, page = 1, limit = 20 }: {
startDate: string;
endDate: string;
page?: number;
limit?: number;
}) => {
const response = await getWaterRecords({
startDate,
endDate,
page,
limit
});
return response;
}
);
// 获取今日喝水统计
export const fetchTodayWaterStats = createAsyncThunk(
'water/fetchTodayWaterStats',
async () => {
const stats = await getTodayWaterStats();
return stats;
}
);
// 创建喝水记录
export const createWaterRecordAction = createAsyncThunk(
'water/createWaterRecord',
async (dto: CreateWaterRecordDto) => {
const newRecord = await createWaterRecord(dto);
return newRecord;
}
);
// 更新喝水记录
export const updateWaterRecordAction = createAsyncThunk(
'water/updateWaterRecord',
async (dto: UpdateWaterRecordDto) => {
const updatedRecord = await updateWaterRecord(dto);
return updatedRecord;
}
);
// 删除喝水记录
export const deleteWaterRecordAction = createAsyncThunk(
'water/deleteWaterRecord',
async (id: string) => {
await deleteWaterRecord(id);
return id;
}
);
// 更新喝水目标
export const updateWaterGoalAction = createAsyncThunk(
'water/updateWaterGoal',
async (dailyWaterGoal: number) => {
const result = await updateWaterGoal({ dailyWaterGoal });
return result.dailyWaterGoal;
}
);
// 创建 slice
const waterSlice = createSlice({
name: 'water',
initialState,
reducers: {
// 设置选中的日期
setSelectedDate: (state, action: PayloadAction<string>) => {
state.selectedDate = action.payload;
},
// 清除错误
clearError: (state) => {
state.error = null;
},
// 清除所有数据
clearWaterData: (state) => {
state.waterRecords = {};
state.todayStats = null;
state.error = null;
},
// 清除喝水记录
clearWaterRecords: (state) => {
state.waterRecords = {};
state.waterRecordsMeta = {};
},
// 设置每日喝水目标(本地)
setDailyWaterGoal: (state, action: PayloadAction<number>) => {
state.dailyWaterGoal = action.payload;
if (state.todayStats) {
state.todayStats.dailyGoal = action.payload;
state.todayStats.completionRate =
(state.todayStats.totalAmount / action.payload) * 100;
}
},
// 添加本地喝水记录(用于离线场景)
addLocalWaterRecord: (state, action: PayloadAction<WaterRecord>) => {
const record = action.payload;
const date = dayjs(record.recordedAt || record.createdAt).format('YYYY-MM-DD');
if (!state.waterRecords[date]) {
state.waterRecords[date] = [];
}
// 检查是否已存在相同ID的记录
const existingIndex = state.waterRecords[date].findIndex(r => r.id === record.id);
if (existingIndex >= 0) {
state.waterRecords[date][existingIndex] = record;
} else {
state.waterRecords[date].push(record);
}
// 更新今日统计
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
state.todayStats.totalAmount += record.amount;
state.todayStats.recordCount += 1;
state.todayStats.completionRate =
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
}
},
// 更新本地喝水记录
updateLocalWaterRecord: (state, action: PayloadAction<WaterRecord>) => {
const updatedRecord = action.payload;
const date = dayjs(updatedRecord.recordedAt || updatedRecord.createdAt).format('YYYY-MM-DD');
if (state.waterRecords[date]) {
const index = state.waterRecords[date].findIndex(r => r.id === updatedRecord.id);
if (index >= 0) {
const oldRecord = state.waterRecords[date][index];
const amountDiff = updatedRecord.amount - oldRecord.amount;
state.waterRecords[date][index] = updatedRecord;
// 更新今日统计
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
state.todayStats.totalAmount += amountDiff;
state.todayStats.completionRate =
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
}
}
}
},
// 删除本地喝水记录
deleteLocalWaterRecord: (state, action: PayloadAction<{ id: string; date: string }>) => {
const { id, date } = action.payload;
if (state.waterRecords[date]) {
const recordIndex = state.waterRecords[date].findIndex(r => r.id === id);
if (recordIndex >= 0) {
const deletedRecord = state.waterRecords[date][recordIndex];
// 从记录中删除
state.waterRecords[date] = state.waterRecords[date].filter(r => r.id !== id);
// 更新今日统计
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
state.todayStats.totalAmount -= deletedRecord.amount;
state.todayStats.recordCount -= 1;
state.todayStats.completionRate =
Math.max(Math.min(state.todayStats.totalAmount / state.todayStats.dailyGoal, 1), 0);
}
}
}
},
},
extraReducers: (builder) => {
// fetchWaterRecords
builder
.addCase(fetchWaterRecords.pending, (state) => {
state.loading.records = true;
state.error = null;
})
.addCase(fetchWaterRecords.fulfilled, (state, action) => {
const { date, records, total, page, limit, hasMore } = action.payload;
// 如果是第一页,直接替换数据;如果是分页加载,则追加数据
if (page === 1) {
state.waterRecords[date] = records;
} else {
const existingRecords = state.waterRecords[date] || [];
state.waterRecords[date] = [...existingRecords, ...records];
}
// 更新分页元数据
state.waterRecordsMeta[date] = {
total,
page,
limit,
hasMore
};
state.loading.records = false;
})
.addCase(fetchWaterRecords.rejected, (state, action) => {
state.loading.records = false;
state.error = action.error.message || '获取喝水记录失败';
});
// fetchWaterRecordsByDateRange
builder
.addCase(fetchWaterRecordsByDateRange.pending, (state) => {
state.loading.records = true;
state.error = null;
})
.addCase(fetchWaterRecordsByDateRange.fulfilled, (state, action) => {
state.loading.records = false;
// 这里可以根据需要处理日期范围的记录
})
.addCase(fetchWaterRecordsByDateRange.rejected, (state, action) => {
state.loading.records = false;
state.error = action.error.message || '获取喝水记录失败';
});
// fetchTodayWaterStats
builder
.addCase(fetchTodayWaterStats.pending, (state) => {
state.loading.stats = true;
state.error = null;
})
.addCase(fetchTodayWaterStats.fulfilled, (state, action) => {
state.loading.stats = false;
state.todayStats = action.payload;
state.dailyWaterGoal = action.payload.dailyGoal;
})
.addCase(fetchTodayWaterStats.rejected, (state, action) => {
state.loading.stats = false;
state.error = action.error.message || '获取喝水统计失败';
});
// createWaterRecord
builder
.addCase(createWaterRecordAction.pending, (state) => {
state.loading.create = true;
state.error = null;
})
.addCase(createWaterRecordAction.fulfilled, (state, action) => {
state.loading.create = false;
const newRecord = action.payload;
const date = dayjs(newRecord.recordedAt || newRecord.createdAt).format('YYYY-MM-DD');
// 添加到对应日期的记录中
if (!state.waterRecords[date]) {
state.waterRecords[date] = [];
}
// 检查是否已存在相同ID的记录
const existingIndex = state.waterRecords[date].findIndex(r => r.id === newRecord.id);
if (existingIndex >= 0) {
state.waterRecords[date][existingIndex] = newRecord;
} else {
state.waterRecords[date].push(newRecord);
}
// 更新今日统计
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
state.todayStats.totalAmount += newRecord.amount;
state.todayStats.recordCount += 1;
state.todayStats.completionRate =
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
}
})
.addCase(createWaterRecordAction.rejected, (state, action) => {
state.loading.create = false;
state.error = action.error.message || '创建喝水记录失败';
});
// updateWaterRecord
builder
.addCase(updateWaterRecordAction.pending, (state) => {
state.loading.update = true;
state.error = null;
})
.addCase(updateWaterRecordAction.fulfilled, (state, action) => {
state.loading.update = false;
const updatedRecord = action.payload;
const date = dayjs(updatedRecord.recordedAt || updatedRecord.createdAt).format('YYYY-MM-DD');
if (state.waterRecords[date]) {
const index = state.waterRecords[date].findIndex(r => r.id === updatedRecord.id);
if (index >= 0) {
const oldRecord = state.waterRecords[date][index];
const amountDiff = updatedRecord.amount - oldRecord.amount;
state.waterRecords[date][index] = updatedRecord;
// 更新今日统计
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
state.todayStats.totalAmount += amountDiff;
state.todayStats.completionRate =
Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100);
}
}
}
})
.addCase(updateWaterRecordAction.rejected, (state, action) => {
state.loading.update = false;
state.error = action.error.message || '更新喝水记录失败';
});
// deleteWaterRecord
builder
.addCase(deleteWaterRecordAction.pending, (state) => {
state.loading.delete = true;
state.error = null;
})
.addCase(deleteWaterRecordAction.fulfilled, (state, action) => {
state.loading.delete = false;
const deletedId = action.payload;
// 从所有日期的记录中删除
Object.keys(state.waterRecords).forEach(date => {
const recordIndex = state.waterRecords[date].findIndex(r => r.id === deletedId);
if (recordIndex >= 0) {
const deletedRecord = state.waterRecords[date][recordIndex];
// 更新今日统计
if (date === dayjs().format('YYYY-MM-DD') && state.todayStats) {
state.todayStats.totalAmount -= deletedRecord.amount;
state.todayStats.recordCount -= 1;
state.todayStats.completionRate =
Math.max(Math.min((state.todayStats.totalAmount / state.todayStats.dailyGoal) * 100, 100), 0);
}
state.waterRecords[date] = state.waterRecords[date].filter(r => r.id !== deletedId);
}
});
})
.addCase(deleteWaterRecordAction.rejected, (state, action) => {
state.loading.delete = false;
state.error = action.error.message || '删除喝水记录失败';
});
// updateWaterGoal
builder
.addCase(updateWaterGoalAction.pending, (state) => {
state.loading.goal = true;
state.error = null;
})
.addCase(updateWaterGoalAction.fulfilled, (state, action) => {
state.loading.goal = false;
state.dailyWaterGoal = action.payload;
if (state.todayStats) {
state.todayStats.dailyGoal = action.payload;
state.todayStats.completionRate =
Math.min((state.todayStats.totalAmount / action.payload) * 100, 100);
}
})
.addCase(updateWaterGoalAction.rejected, (state, action) => {
state.loading.goal = false;
state.error = action.error.message || '更新喝水目标失败';
});
},
});
// 导出 actions
export const {
setSelectedDate,
clearError,
clearWaterData,
clearWaterRecords,
setDailyWaterGoal,
addLocalWaterRecord,
updateLocalWaterRecord,
deleteLocalWaterRecord,
} = waterSlice.actions;
// 选择器函数
export const selectWaterState = (state: RootState) => state.water;
// 选择今日统计
export const selectTodayStats = (state: RootState) => selectWaterState(state).todayStats;
// 选择每日喝水目标
export const selectDailyWaterGoal = (state: RootState) => selectWaterState(state).dailyWaterGoal;
// 选择指定日期的喝水记录
export const selectWaterRecordsByDate = (date: string) => (state: RootState) => {
return selectWaterState(state).waterRecords[date] || [];
};
// 选择当前选中日期的喝水记录
export const selectSelectedDateWaterRecords = (state: RootState) => {
const selectedDate = selectWaterState(state).selectedDate;
return selectWaterRecordsByDate(selectedDate)(state);
};
// 选择加载状态
export const selectWaterLoading = (state: RootState) => selectWaterState(state).loading;
// 选择错误信息
export const selectWaterError = (state: RootState) => selectWaterState(state).error;
// 选择当前选中日期
export const selectSelectedDate = (state: RootState) => selectWaterState(state).selectedDate;
// 导出 reducer
export default waterSlice.reducer;

135
test-water-api.md Normal file
View File

@@ -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 接口

View File

@@ -319,8 +319,6 @@ export class NutritionNotificationHelpers {
// 检查是否已经存在午餐提醒 // 检查是否已经存在午餐提醒
const existingNotifications = await notificationService.getAllScheduledNotifications(); const existingNotifications = await notificationService.getAllScheduledNotifications();
console.log('existingNotifications', existingNotifications);
const existingLunchReminder = existingNotifications.find( const existingLunchReminder = existingNotifications.find(
notification => notification =>
notification.content.data?.type === 'lunch_reminder' && notification.content.data?.type === 'lunch_reminder' &&