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