feat: 新增营养记录页面及相关组件
- 在应用中新增营养记录页面,展示用户的饮食记录 - 引入营养记录卡片组件,优化记录展示效果 - 更新路由常量,添加营养记录相关路径 - 修改布局文件,整合营养记录功能 - 优化数据加载逻辑,支持分页和日期过滤
This commit is contained in:
@@ -16,7 +16,6 @@ import Toast from 'react-native-toast-message';
|
|||||||
|
|
||||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
|
||||||
|
|
||||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -29,7 +28,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
await dispatch(rehydrateUser());
|
await dispatch(rehydrateUser());
|
||||||
setUserDataLoaded(true);
|
setUserDataLoaded(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
loadUserData();
|
loadUserData();
|
||||||
// 冷启动时清空 AI 教练会话缓存
|
// 冷启动时清空 AI 教练会话缓存
|
||||||
clearAiCoachSessionCache();
|
clearAiCoachSessionCache();
|
||||||
@@ -48,7 +47,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePrivacyDisagree = () => {
|
const handlePrivacyDisagree = () => {
|
||||||
RNExitApp.exitApp();
|
RNExitApp.exitApp();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +92,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="nutrition/records" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
|||||||
9
app/nutrition/_layout.tsx
Normal file
9
app/nutrition/_layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function NutritionLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="records" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
466
app/nutrition/records.tsx
Normal file
466
app/nutrition/records.tsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { DietRecord } from '@/services/dietRecords';
|
||||||
|
import { getMockDietRecords } from '@/services/mockDietRecords';
|
||||||
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
// import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
type ViewMode = 'daily' | 'all';
|
||||||
|
|
||||||
|
export default function NutritionRecordsScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
// const tabBarHeight = useBottomTabBarHeight();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// 日期相关状态 - 使用与统计页面相同的日期逻辑
|
||||||
|
const days = getMonthDaysZh();
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
|
const monthTitle = getMonthTitleZh();
|
||||||
|
|
||||||
|
// 视图模式:按天查看 vs 全部查看
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('daily');
|
||||||
|
|
||||||
|
// 数据状态
|
||||||
|
const [records, setRecords] = useState<DietRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [hasMoreData, setHasMoreData] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// 日期滚动相关
|
||||||
|
const daysScrollRef = useRef<ScrollView | null>(null);
|
||||||
|
const [scrollWidth, setScrollWidth] = useState(0);
|
||||||
|
const DAY_PILL_WIDTH = 68;
|
||||||
|
const DAY_PILL_SPACING = 12;
|
||||||
|
|
||||||
|
// 日期滚动控制
|
||||||
|
const scrollToIndex = (index: number, animated = true) => {
|
||||||
|
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
|
||||||
|
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
|
||||||
|
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollWidth > 0) {
|
||||||
|
scrollToIndex(selectedIndex, false);
|
||||||
|
}
|
||||||
|
}, [scrollWidth, selectedIndex]);
|
||||||
|
|
||||||
|
// 加载记录数据
|
||||||
|
const loadRecords = async (isRefresh = false, loadMore = false) => {
|
||||||
|
try {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
setPage(1);
|
||||||
|
} else if (loadMore) {
|
||||||
|
// 加载更多时不显示loading
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = isRefresh ? 1 : (loadMore ? page + 1 : 1);
|
||||||
|
|
||||||
|
let startDate: string | undefined;
|
||||||
|
let endDate: string | undefined;
|
||||||
|
|
||||||
|
if (viewMode === 'daily') {
|
||||||
|
// 按天查看时,获取选中日期的数据
|
||||||
|
const selectedDate = days[selectedIndex]?.date?.format('YYYY-MM-DD') ?? dayjs().format('YYYY-MM-DD');
|
||||||
|
startDate = selectedDate;
|
||||||
|
endDate = selectedDate;
|
||||||
|
}
|
||||||
|
// viewMode === 'all' 时不设置日期范围,获取所有数据
|
||||||
|
|
||||||
|
// 使用模拟数据进行测试
|
||||||
|
// const data = await getDietRecords({
|
||||||
|
// startDate,
|
||||||
|
// endDate,
|
||||||
|
// page: currentPage,
|
||||||
|
// limit: 10,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// 模拟网络延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
const data = getMockDietRecords({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page: currentPage,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRefresh || currentPage === 1) {
|
||||||
|
setRecords(data.records);
|
||||||
|
} else {
|
||||||
|
setRecords(prev => [...prev, ...data.records]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMoreData(data.records.length === 10); // 如果返回的记录数少于limit,说明没有更多数据
|
||||||
|
setPage(currentPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载营养记录失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当选中日期或视图模式变化时重新加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecords();
|
||||||
|
}, [selectedIndex, viewMode]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
loadRecords(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreRecords = () => {
|
||||||
|
if (hasMoreData && !loading && !refreshing) {
|
||||||
|
loadRecords(false, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染视图模式切换器
|
||||||
|
const renderViewModeToggle = () => (
|
||||||
|
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.surface }]}>
|
||||||
|
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
||||||
|
<View style={[styles.toggleContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.toggleButton,
|
||||||
|
viewMode === 'daily' && { backgroundColor: colorTokens.primary }
|
||||||
|
]}
|
||||||
|
onPress={() => setViewMode('daily')}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.toggleText,
|
||||||
|
{ color: viewMode === 'daily' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||||||
|
]}>
|
||||||
|
按天查看
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.toggleButton,
|
||||||
|
viewMode === 'all' && { backgroundColor: colorTokens.primary }
|
||||||
|
]}
|
||||||
|
onPress={() => setViewMode('all')}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.toggleText,
|
||||||
|
{ color: viewMode === 'all' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||||||
|
]}>
|
||||||
|
全部记录
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染日期选择器(仅在按天查看模式下显示)
|
||||||
|
const renderDateSelector = () => {
|
||||||
|
if (viewMode !== 'daily') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.daysContainer}>
|
||||||
|
<ScrollView
|
||||||
|
ref={daysScrollRef}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.daysScrollContainer}
|
||||||
|
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
||||||
|
>
|
||||||
|
{days.map((day, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const isToday = day.isToday;
|
||||||
|
const isDisabled = day.date?.isAfter(dayjs(), 'day') ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.dayPill,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? colorTokens.primary
|
||||||
|
: colorTokens.surface,
|
||||||
|
borderColor: isToday && !isSelected
|
||||||
|
? colorTokens.primary
|
||||||
|
: 'transparent',
|
||||||
|
borderWidth: isToday && !isSelected ? 1 : 0,
|
||||||
|
opacity: isDisabled ? 0.4 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
if (!isDisabled) {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
scrollToIndex(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dayNumber,
|
||||||
|
{
|
||||||
|
color: isSelected
|
||||||
|
? colorTokens.onPrimary
|
||||||
|
: colorTokens.text,
|
||||||
|
fontWeight: isSelected || isToday ? '700' : '600',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{day.date?.date() ?? ''}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dayLabel,
|
||||||
|
{
|
||||||
|
color: isSelected
|
||||||
|
? colorTokens.onPrimary
|
||||||
|
: colorTokens.textSecondary,
|
||||||
|
fontWeight: isSelected ? '600' : '500',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{day.dayAbbr}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmptyState = () => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons name="restaurant-outline" size={64} color={colorTokens.textSecondary} />
|
||||||
|
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
|
||||||
|
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
{viewMode === 'daily' ? '点击右上角添加今日营养摄入' : '开始记录你的营养摄入吧'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRecord = ({ item }: { item: DietRecord }) => (
|
||||||
|
<NutritionRecordCard record={item} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
if (!hasMoreData) {
|
||||||
|
return (
|
||||||
|
<View style={styles.footerContainer}>
|
||||||
|
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
|
||||||
|
没有更多数据了
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'all' && records.length > 0) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||||||
|
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||||||
|
加载更多
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<HeaderBar
|
||||||
|
title="营养记录"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
right={
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.addButton, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={() => {
|
||||||
|
// TODO: 跳转到添加营养记录页面
|
||||||
|
console.log('添加营养记录');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={20} color={colorTokens.onPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderViewModeToggle()}
|
||||||
|
{renderDateSelector()}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colorTokens.primary} />
|
||||||
|
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>
|
||||||
|
加载中...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={records}
|
||||||
|
renderItem={renderRecord}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.listContainer,
|
||||||
|
{ paddingBottom: 40 }
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={colorTokens.primary}
|
||||||
|
colors={[colorTokens.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
|
ListFooterComponent={renderFooter}
|
||||||
|
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
viewModeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
monthTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
toggleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 2,
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 18,
|
||||||
|
minWidth: 80,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
toggleText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
daysContainer: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
daysScrollContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
dayPill: {
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
borderRadius: 34,
|
||||||
|
marginRight: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
fontSize: 18,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
dayLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
footerContainer: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
loadMoreButton: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadMoreText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { NutritionSummary } from '@/services/dietRecords';
|
import { NutritionSummary } from '@/services/dietRecords';
|
||||||
import Feather from '@expo/vector-icons/Feather';
|
import Feather from '@expo/vector-icons/Feather';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { router } from 'expo-router';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { RadarCategory, RadarChart } from './RadarChart';
|
import { RadarCategory, RadarChart } from './RadarChart';
|
||||||
|
|
||||||
export type NutritionRadarCardProps = {
|
export type NutritionRadarCardProps = {
|
||||||
@@ -55,8 +57,12 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
|
|||||||
];
|
];
|
||||||
}, [nutritionSummary]);
|
}, [nutritionSummary]);
|
||||||
|
|
||||||
|
const handleNavigateToRecords = () => {
|
||||||
|
router.push(ROUTES.NUTRITION_RECORDS);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
||||||
<View style={styles.cardRightContainer}>
|
<View style={styles.cardRightContainer}>
|
||||||
@@ -92,7 +98,7 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
358
components/NutritionRecordCard.tsx
Normal file
358
components/NutritionRecordCard.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { RadarChart } from '@/components/RadarChart';
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
|
import { DietRecord, calculateNutritionSummary, convertToRadarData } from '@/services/dietRecords';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Image, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
export type NutritionRecordCardProps = {
|
||||||
|
record: DietRecord;
|
||||||
|
onPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUTRITION_DIMENSIONS = [
|
||||||
|
{ key: 'calories', label: '热量' },
|
||||||
|
{ key: 'protein', label: '蛋白质' },
|
||||||
|
{ key: 'carbohydrate', label: '碳水' },
|
||||||
|
{ key: 'fat', label: '脂肪' },
|
||||||
|
{ key: 'fiber', label: '纤维' },
|
||||||
|
{ key: 'sodium', label: '钠' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEAL_TYPE_LABELS = {
|
||||||
|
breakfast: '早餐',
|
||||||
|
lunch: '午餐',
|
||||||
|
dinner: '晚餐',
|
||||||
|
snack: '加餐',
|
||||||
|
other: '其他',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const MEAL_TYPE_ICONS = {
|
||||||
|
breakfast: 'sunny-outline',
|
||||||
|
lunch: 'partly-sunny-outline',
|
||||||
|
dinner: 'moon-outline',
|
||||||
|
snack: 'cafe-outline',
|
||||||
|
other: 'restaurant-outline',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const MEAL_TYPE_COLORS = {
|
||||||
|
breakfast: '#FFB366',
|
||||||
|
lunch: '#4ECDC4',
|
||||||
|
dinner: '#5D5FEF',
|
||||||
|
snack: '#FF6B6B',
|
||||||
|
other: '#9AA3AE',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function NutritionRecordCard({ record, onPress }: NutritionRecordCardProps) {
|
||||||
|
const surfaceColor = useThemeColor({}, 'surface');
|
||||||
|
const textColor = useThemeColor({}, 'text');
|
||||||
|
const textSecondaryColor = useThemeColor({}, 'textSecondary');
|
||||||
|
const primaryColor = useThemeColor({}, 'primary');
|
||||||
|
|
||||||
|
// 计算单条记录的营养摘要
|
||||||
|
const nutritionSummary = useMemo(() => {
|
||||||
|
return calculateNutritionSummary([record]);
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
// 计算雷达图数据
|
||||||
|
const radarValues = useMemo(() => {
|
||||||
|
return convertToRadarData(nutritionSummary);
|
||||||
|
}, [nutritionSummary]);
|
||||||
|
|
||||||
|
// 营养维度数据
|
||||||
|
const nutritionStats = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '热量',
|
||||||
|
value: record.estimatedCalories ? `${Math.round(record.estimatedCalories)} 千卡` : '-',
|
||||||
|
color: '#FF6B6B'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '蛋白质',
|
||||||
|
value: record.proteinGrams ? `${record.proteinGrams.toFixed(1)} g` : '-',
|
||||||
|
color: '#4ECDC4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '碳水',
|
||||||
|
value: record.carbohydrateGrams ? `${record.carbohydrateGrams.toFixed(1)} g` : '-',
|
||||||
|
color: '#45B7D1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '脂肪',
|
||||||
|
value: record.fatGrams ? `${record.fatGrams.toFixed(1)} g` : '-',
|
||||||
|
color: '#FFA07A'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '纤维',
|
||||||
|
value: record.fiberGrams ? `${record.fiberGrams.toFixed(1)} g` : '-',
|
||||||
|
color: '#98D8C8'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '钠',
|
||||||
|
value: record.sodiumMg ? `${Math.round(record.sodiumMg)} mg` : '-',
|
||||||
|
color: '#F7DC6F'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
const mealTypeColor = MEAL_TYPE_COLORS[record.mealType];
|
||||||
|
const mealTypeIcon = MEAL_TYPE_ICONS[record.mealType];
|
||||||
|
const mealTypeLabel = MEAL_TYPE_LABELS[record.mealType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, { backgroundColor: surfaceColor }]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{/* 卡片头部 */}
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.mealInfo}>
|
||||||
|
<View style={[styles.mealTypeIndicator, { backgroundColor: mealTypeColor }]}>
|
||||||
|
<Ionicons name={mealTypeIcon as any} size={16} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.mealDetails}>
|
||||||
|
<ThemedText style={[styles.mealType, { color: textColor }]}>
|
||||||
|
{mealTypeLabel}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={[styles.mealTime, { color: textSecondaryColor }]}>
|
||||||
|
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '时间未设置'}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.moreButton}>
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={20} color={textSecondaryColor} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 食物信息 */}
|
||||||
|
<View style={styles.foodSection}>
|
||||||
|
<View style={[styles.foodImageContainer, !record.imageUrl && styles.foodImagePlaceholder]}>
|
||||||
|
{record.imageUrl ? (
|
||||||
|
<Image source={{ uri: record.imageUrl }} style={styles.foodImage} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="restaurant" size={24} color={textSecondaryColor} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.foodInfo}>
|
||||||
|
<ThemedText style={[styles.foodName, { color: textColor }]}>
|
||||||
|
{record.foodName}
|
||||||
|
</ThemedText>
|
||||||
|
{record.foodDescription && (
|
||||||
|
<ThemedText style={[styles.foodDescription, { color: textSecondaryColor }]}>
|
||||||
|
{record.foodDescription}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
{(record.weightGrams || record.portionDescription) && (
|
||||||
|
<ThemedText style={[styles.portionInfo, { color: textSecondaryColor }]}>
|
||||||
|
{record.weightGrams ? `${record.weightGrams}g` : ''}
|
||||||
|
{record.weightGrams && record.portionDescription ? ' • ' : ''}
|
||||||
|
{record.portionDescription || ''}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 营养分析区域 */}
|
||||||
|
<View style={styles.nutritionSection}>
|
||||||
|
<View style={styles.radarContainer}>
|
||||||
|
<RadarChart
|
||||||
|
categories={NUTRITION_DIMENSIONS}
|
||||||
|
values={radarValues}
|
||||||
|
size="small"
|
||||||
|
maxValue={5}
|
||||||
|
showLabels={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
{nutritionStats.slice(0, 4).map((stat) => (
|
||||||
|
<View key={stat.label} style={styles.statItem}>
|
||||||
|
<View style={[styles.statDot, { backgroundColor: stat.color }]} />
|
||||||
|
<ThemedText style={[styles.statLabel, { color: textSecondaryColor }]}>
|
||||||
|
{stat.label}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={[styles.statValue, { color: textColor }]}>
|
||||||
|
{stat.value}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 额外的营养信息 */}
|
||||||
|
<View style={styles.additionalStats}>
|
||||||
|
{nutritionStats.slice(4).map((stat) => (
|
||||||
|
<View key={stat.label} style={styles.additionalStatItem}>
|
||||||
|
<View style={[styles.statDot, { backgroundColor: stat.color }]} />
|
||||||
|
<ThemedText style={[styles.statLabel, { color: textSecondaryColor }]}>
|
||||||
|
{stat.label}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={[styles.statValue, { color: textColor }]}>
|
||||||
|
{stat.value}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 备注信息 */}
|
||||||
|
{record.notes && (
|
||||||
|
<View style={styles.notesSection}>
|
||||||
|
<ThemedText style={[styles.notesLabel, { color: textSecondaryColor }]}>
|
||||||
|
备注
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={[styles.notesText, { color: textColor }]}>
|
||||||
|
{record.notes}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
mealInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
mealTypeIndicator: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
mealDetails: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
mealType: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
mealTime: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
moreButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
foodSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
foodImageContainer: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginRight: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
foodImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
foodImagePlaceholder: {
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
foodInfo: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
foodName: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
foodDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
portionInfo: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
nutritionSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
radarContainer: {
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
statDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
additionalStats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
additionalStatItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
notesSection: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(0,0,0,0.08)',
|
||||||
|
},
|
||||||
|
notesLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
notesText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -36,6 +36,9 @@ export const ROUTES = {
|
|||||||
// 引导页路由
|
// 引导页路由
|
||||||
ONBOARDING: '/onboarding',
|
ONBOARDING: '/onboarding',
|
||||||
ONBOARDING_PERSONAL_INFO: '/onboarding/personal-info',
|
ONBOARDING_PERSONAL_INFO: '/onboarding/personal-info',
|
||||||
|
|
||||||
|
// 营养相关路由
|
||||||
|
NUTRITION_RECORDS: '/nutrition/records',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 路由参数常量
|
// 路由参数常量
|
||||||
|
|||||||
@@ -40,16 +40,26 @@ export type NutritionSummary = {
|
|||||||
export async function getDietRecords({
|
export async function getDietRecords({
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
}: {
|
}: {
|
||||||
startDate: string;
|
startDate?: string;
|
||||||
endDate: string;
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
records: DietRecord[]
|
records: DietRecord[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
limit: number
|
limit: number
|
||||||
}> {
|
}> {
|
||||||
const params = startDate && endDate ? `?startDate=${startDate}&endDate=${endDate}` : '';
|
const searchParams = new URLSearchParams();
|
||||||
|
if (startDate) searchParams.append('startDate', startDate);
|
||||||
|
if (endDate) searchParams.append('endDate', endDate);
|
||||||
|
searchParams.append('page', page.toString());
|
||||||
|
searchParams.append('limit', limit.toString());
|
||||||
|
|
||||||
|
const params = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||||
return await api.get<{
|
return await api.get<{
|
||||||
records: DietRecord[]
|
records: DietRecord[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
211
services/mockDietRecords.ts
Normal file
211
services/mockDietRecords.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { DietRecord } from './dietRecords';
|
||||||
|
|
||||||
|
// 模拟营养记录数据,用于测试UI效果
|
||||||
|
export const mockDietRecords: DietRecord[] = [
|
||||||
|
// 今天的记录
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
mealType: 'breakfast',
|
||||||
|
foodName: '燕麦粥配蓝莓',
|
||||||
|
foodDescription: '有机燕麦片,新鲜蓝莓,低脂牛奶',
|
||||||
|
weightGrams: 300,
|
||||||
|
portionDescription: '1大碗',
|
||||||
|
estimatedCalories: 280,
|
||||||
|
proteinGrams: 12.5,
|
||||||
|
carbohydrateGrams: 45.2,
|
||||||
|
fatGrams: 6.8,
|
||||||
|
fiberGrams: 8.5,
|
||||||
|
sugarGrams: 15.3,
|
||||||
|
sodiumMg: 120,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: dayjs().hour(7).minute(30).toISOString(),
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1511690743698-d9d85f2fbf38?w=300&h=300&fit=crop',
|
||||||
|
notes: '营养丰富的早餐,富含膳食纤维和抗氧化物质',
|
||||||
|
createdAt: dayjs().hour(7).minute(35).toISOString(),
|
||||||
|
updatedAt: dayjs().hour(7).minute(35).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
mealType: 'lunch',
|
||||||
|
foodName: '鸡胸肉沙拉',
|
||||||
|
foodDescription: '烤鸡胸肉,混合蔬菜,橄榄油调味',
|
||||||
|
weightGrams: 250,
|
||||||
|
portionDescription: '1份',
|
||||||
|
estimatedCalories: 320,
|
||||||
|
proteinGrams: 35.6,
|
||||||
|
carbohydrateGrams: 8.4,
|
||||||
|
fatGrams: 15.2,
|
||||||
|
fiberGrams: 6.2,
|
||||||
|
sugarGrams: 5.8,
|
||||||
|
sodiumMg: 480,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: dayjs().hour(12).minute(15).toISOString(),
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=300&h=300&fit=crop',
|
||||||
|
notes: '高蛋白低碳水,适合健身人群',
|
||||||
|
createdAt: dayjs().hour(12).minute(20).toISOString(),
|
||||||
|
updatedAt: dayjs().hour(12).minute(20).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
mealType: 'snack',
|
||||||
|
foodName: '混合坚果',
|
||||||
|
foodDescription: '杏仁,核桃,腰果混合装',
|
||||||
|
weightGrams: 30,
|
||||||
|
portionDescription: '1小包',
|
||||||
|
estimatedCalories: 180,
|
||||||
|
proteinGrams: 6.5,
|
||||||
|
carbohydrateGrams: 6.8,
|
||||||
|
fatGrams: 15.5,
|
||||||
|
fiberGrams: 3.2,
|
||||||
|
sugarGrams: 2.1,
|
||||||
|
sodiumMg: 5,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: dayjs().hour(15).minute(30).toISOString(),
|
||||||
|
notes: '健康的下午茶零食',
|
||||||
|
createdAt: dayjs().hour(15).minute(35).toISOString(),
|
||||||
|
updatedAt: dayjs().hour(15).minute(35).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
mealType: 'dinner',
|
||||||
|
foodName: '三文鱼配蒸蔬菜',
|
||||||
|
foodDescription: '挪威三文鱼,西兰花,胡萝卜',
|
||||||
|
weightGrams: 350,
|
||||||
|
portionDescription: '1份',
|
||||||
|
estimatedCalories: 420,
|
||||||
|
proteinGrams: 42.3,
|
||||||
|
carbohydrateGrams: 12.5,
|
||||||
|
fatGrams: 22.8,
|
||||||
|
fiberGrams: 5.6,
|
||||||
|
sugarGrams: 8.2,
|
||||||
|
sodiumMg: 380,
|
||||||
|
source: 'vision',
|
||||||
|
mealTime: dayjs().hour(19).minute(0).toISOString(),
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=300&h=300&fit=crop',
|
||||||
|
notes: '富含Omega-3脂肪酸,有益心血管健康',
|
||||||
|
createdAt: dayjs().hour(19).minute(10).toISOString(),
|
||||||
|
updatedAt: dayjs().hour(19).minute(10).toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 昨天的记录
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
mealType: 'breakfast',
|
||||||
|
foodName: '希腊酸奶杯',
|
||||||
|
foodDescription: '无糖希腊酸奶,草莓,燕麦片',
|
||||||
|
weightGrams: 200,
|
||||||
|
portionDescription: '1杯',
|
||||||
|
estimatedCalories: 220,
|
||||||
|
proteinGrams: 20.4,
|
||||||
|
carbohydrateGrams: 18.6,
|
||||||
|
fatGrams: 8.2,
|
||||||
|
fiberGrams: 4.1,
|
||||||
|
sugarGrams: 12.5,
|
||||||
|
sodiumMg: 85,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: dayjs().subtract(1, 'day').hour(8).minute(0).toISOString(),
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1488477181946-6428a0291777?w=300&h=300&fit=crop',
|
||||||
|
notes: '高蛋白早餐,饱腹感强',
|
||||||
|
createdAt: dayjs().subtract(1, 'day').hour(8).minute(5).toISOString(),
|
||||||
|
updatedAt: dayjs().subtract(1, 'day').hour(8).minute(5).toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更多历史记录,用于测试分页
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
mealType: 'lunch',
|
||||||
|
foodName: '牛肉面',
|
||||||
|
foodDescription: '手拉面条,牛肉汤底,青菜',
|
||||||
|
weightGrams: 400,
|
||||||
|
portionDescription: '1碗',
|
||||||
|
estimatedCalories: 580,
|
||||||
|
proteinGrams: 28.0,
|
||||||
|
carbohydrateGrams: 65.0,
|
||||||
|
fatGrams: 18.5,
|
||||||
|
fiberGrams: 4.8,
|
||||||
|
sugarGrams: 6.2,
|
||||||
|
sodiumMg: 1200,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: dayjs().subtract(2, 'day').hour(13).minute(0).toISOString(),
|
||||||
|
notes: '传统中式午餐',
|
||||||
|
createdAt: dayjs().subtract(2, 'day').hour(13).minute(10).toISOString(),
|
||||||
|
updatedAt: dayjs().subtract(2, 'day').hour(13).minute(10).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
mealType: 'breakfast',
|
||||||
|
foodName: '全麦吐司配牛油果',
|
||||||
|
foodDescription: '全麦面包,新鲜牛油果,煎蛋',
|
||||||
|
weightGrams: 180,
|
||||||
|
portionDescription: '2片吐司',
|
||||||
|
estimatedCalories: 350,
|
||||||
|
proteinGrams: 15.2,
|
||||||
|
carbohydrateGrams: 28.5,
|
||||||
|
fatGrams: 22.0,
|
||||||
|
fiberGrams: 8.0,
|
||||||
|
sugarGrams: 3.5,
|
||||||
|
sodiumMg: 220,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: dayjs().subtract(3, 'day').hour(8).minute(30).toISOString(),
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1541519227354-08fa5d50c44d?w=300&h=300&fit=crop',
|
||||||
|
notes: '健康脂肪和蛋白质的完美组合',
|
||||||
|
createdAt: dayjs().subtract(3, 'day').hour(8).minute(35).toISOString(),
|
||||||
|
updatedAt: dayjs().subtract(3, 'day').hour(8).minute(35).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
mealType: 'dinner',
|
||||||
|
foodName: '蒸蛋羹',
|
||||||
|
foodDescription: '鸡蛋,温水,少许盐',
|
||||||
|
weightGrams: 150,
|
||||||
|
portionDescription: '1小碗',
|
||||||
|
estimatedCalories: 140,
|
||||||
|
proteinGrams: 12.0,
|
||||||
|
carbohydrateGrams: 1.0,
|
||||||
|
fatGrams: 10.0,
|
||||||
|
fiberGrams: 0,
|
||||||
|
sugarGrams: 1.0,
|
||||||
|
sodiumMg: 180,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: dayjs().subtract(4, 'day').hour(18).minute(45).toISOString(),
|
||||||
|
notes: '清淡易消化的晚餐',
|
||||||
|
createdAt: dayjs().subtract(4, 'day').hour(19).minute(0).toISOString(),
|
||||||
|
updatedAt: dayjs().subtract(4, 'day').hour(19).minute(0).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟API响应,支持分页和日期过滤
|
||||||
|
export function getMockDietRecords({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
}: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
let filteredRecords = mockDietRecords;
|
||||||
|
|
||||||
|
// 如果有日期范围,则过滤
|
||||||
|
if (startDate && endDate) {
|
||||||
|
filteredRecords = mockDietRecords.filter(record => {
|
||||||
|
const recordDate = dayjs(record.mealTime).format('YYYY-MM-DD');
|
||||||
|
return recordDate >= startDate && recordDate <= endDate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const startIndex = (page - 1) * limit;
|
||||||
|
const endIndex = startIndex + limit;
|
||||||
|
const paginatedRecords = filteredRecords.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
return {
|
||||||
|
records: paginatedRecords,
|
||||||
|
total: filteredRecords.length,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,10 +29,14 @@ export function getMonthTitleZh(date: Dayjs = dayjs()): string {
|
|||||||
export type MonthDay = {
|
export type MonthDay = {
|
||||||
/** 中文星期:日/一/二/三/四/五/六 */
|
/** 中文星期:日/一/二/三/四/五/六 */
|
||||||
weekdayZh: string;
|
weekdayZh: string;
|
||||||
|
/** 简化的星期,用于显示 */
|
||||||
|
dayAbbr: string;
|
||||||
/** 月内第几日(1-31) */
|
/** 月内第几日(1-31) */
|
||||||
dayOfMonth: number;
|
dayOfMonth: number;
|
||||||
/** 对应的 dayjs 对象 */
|
/** 对应的 dayjs 对象 */
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
|
/** 是否是今天 */
|
||||||
|
isToday: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 获取某月的所有日期(中文星期+日号) */
|
/** 获取某月的所有日期(中文星期+日号) */
|
||||||
@@ -41,12 +45,18 @@ export function getMonthDaysZh(date: Dayjs = dayjs()): MonthDay[] {
|
|||||||
const monthIndex = date.month();
|
const monthIndex = date.month();
|
||||||
const daysInMonth = date.daysInMonth();
|
const daysInMonth = date.daysInMonth();
|
||||||
const zhWeek = ['日', '一', '二', '三', '四', '五', '六'];
|
const zhWeek = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
const today = dayjs();
|
||||||
|
|
||||||
return Array.from({ length: daysInMonth }, (_, i) => {
|
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||||
const d = dayjs(new Date(year, monthIndex, i + 1));
|
const d = dayjs(new Date(year, monthIndex, i + 1));
|
||||||
|
const isToday = d.isSame(today, 'day');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
weekdayZh: zhWeek[d.day()],
|
weekdayZh: zhWeek[d.day()],
|
||||||
|
dayAbbr: zhWeek[d.day()],
|
||||||
dayOfMonth: i + 1,
|
dayOfMonth: i + 1,
|
||||||
date: d,
|
date: d,
|
||||||
|
isToday,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user