feat: 新增营养记录页面及相关组件

- 在应用中新增营养记录页面,展示用户的饮食记录
- 引入营养记录卡片组件,优化记录展示效果
- 更新路由常量,添加营养记录相关路径
- 修改布局文件,整合营养记录功能
- 优化数据加载逻辑,支持分页和日期过滤
This commit is contained in:
richarjiang
2025-08-19 11:34:50 +08:00
parent df2afeb5a1
commit 260546ff46
9 changed files with 1082 additions and 9 deletions

View File

@@ -16,7 +16,6 @@ import Toast from 'react-native-toast-message';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { Provider } from 'react-redux';
import { ROUTES } from '@/constants/Routes';
function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
@@ -93,6 +92,7 @@ export default function RootLayout() {
<Stack.Screen name="legal/user-agreement" 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="nutrition/records" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />

View 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
View 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',
},
});

View File

@@ -1,8 +1,10 @@
import { ROUTES } from '@/constants/Routes';
import { NutritionSummary } from '@/services/dietRecords';
import Feather from '@expo/vector-icons/Feather';
import dayjs from 'dayjs';
import { router } from 'expo-router';
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';
export type NutritionRadarCardProps = {
@@ -55,8 +57,12 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
];
}, [nutritionSummary]);
const handleNavigateToRecords = () => {
router.push(ROUTES.NUTRITION_RECORDS);
};
return (
<View style={styles.card}>
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<View style={styles.cardRightContainer}>
@@ -92,7 +98,7 @@ export function NutritionRadarCard({ nutritionSummary, isLoading = false }: Nutr
</View>
</View>
)}
</View>
</TouchableOpacity>
);
}

View 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,
},
});

View File

@@ -36,6 +36,9 @@ export const ROUTES = {
// 引导页路由
ONBOARDING: '/onboarding',
ONBOARDING_PERSONAL_INFO: '/onboarding/personal-info',
// 营养相关路由
NUTRITION_RECORDS: '/nutrition/records',
} as const;
// 路由参数常量

View File

@@ -40,16 +40,26 @@ export type NutritionSummary = {
export async function getDietRecords({
startDate,
endDate,
page = 1,
limit = 10,
}: {
startDate: string;
endDate: string;
startDate?: string;
endDate?: string;
page?: number;
limit?: number;
}): Promise<{
records: DietRecord[]
total: number
page: 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<{
records: DietRecord[]
total: number

211
services/mockDietRecords.ts Normal file
View 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,
};
}

View File

@@ -29,10 +29,14 @@ export function getMonthTitleZh(date: Dayjs = dayjs()): string {
export type MonthDay = {
/** 中文星期:日/一/二/三/四/五/六 */
weekdayZh: string;
/** 简化的星期,用于显示 */
dayAbbr: string;
/** 月内第几日1-31 */
dayOfMonth: number;
/** 对应的 dayjs 对象 */
date: Dayjs;
/** 是否是今天 */
isToday: boolean;
};
/** 获取某月的所有日期(中文星期+日号) */
@@ -41,12 +45,18 @@ export function getMonthDaysZh(date: Dayjs = dayjs()): MonthDay[] {
const monthIndex = date.month();
const daysInMonth = date.daysInMonth();
const zhWeek = ['日', '一', '二', '三', '四', '五', '六'];
const today = dayjs();
return Array.from({ length: daysInMonth }, (_, i) => {
const d = dayjs(new Date(year, monthIndex, i + 1));
const isToday = d.isSame(today, 'day');
return {
weekdayZh: zhWeek[d.day()],
dayAbbr: zhWeek[d.day()],
dayOfMonth: i + 1,
date: d,
isToday,
};
});
}