feat: 新增营养摄入分析卡片并优化相关页面
- 在统计页面中引入营养摄入分析卡片,展示用户的营养数据 - 更新探索页面,增加营养数据加载逻辑,确保用户体验一致性 - 移除不再使用的推荐文章逻辑,简化代码结构 - 更新路由常量,确保路径管理集中化 - 优化体重历史记录卡片,提升用户交互体验
This commit is contained in:
@@ -6,17 +6,16 @@ import { ThemedView } from '@/components/ThemedView';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { listRecommendedArticles } from '@/services/articles';
|
|
||||||
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
||||||
import { loadPlans } from '@/store/trainingPlanSlice';
|
import { loadPlans } from '@/store/trainingPlanSlice';
|
||||||
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||||
|
import { QUERY_PARAMS, ROUTE_PARAMS, ROUTES } from '@/constants/Routes';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { TrainingPlan } from '@/services/trainingPlanApi';
|
import { TrainingPlan } from '@/services/trainingPlanApi';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { ROUTES, QUERY_PARAMS, ROUTE_PARAMS } from '@/constants/Routes';
|
|
||||||
|
|
||||||
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
||||||
|
|
||||||
@@ -103,42 +102,7 @@ export default function HomeScreen() {
|
|||||||
readCount: number;
|
readCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打底数据(接口不可用时)
|
const [items, setItems] = React.useState<RecommendItem[]>();
|
||||||
const getFallbackItems = React.useCallback((): RecommendItem[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'plan',
|
|
||||||
key: 'today-workout',
|
|
||||||
image:
|
|
||||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
|
||||||
title: '今日训练',
|
|
||||||
subtitle: '完成一次普拉提训练,记录你的坚持',
|
|
||||||
level: '初学者',
|
|
||||||
onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'plan',
|
|
||||||
key: 'assess',
|
|
||||||
image:
|
|
||||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
|
||||||
title: '体态评估',
|
|
||||||
subtitle: '评估你的体态,制定训练计划',
|
|
||||||
level: '初学者',
|
|
||||||
onPress: () => router.push(ROUTES.AI_POSTURE_ASSESSMENT),
|
|
||||||
},
|
|
||||||
...listRecommendedArticles().map((a) => ({
|
|
||||||
type: 'article' as const,
|
|
||||||
key: `article-${a.id}`,
|
|
||||||
id: a.id,
|
|
||||||
title: a.title,
|
|
||||||
coverImage: a.coverImage,
|
|
||||||
publishedAt: a.publishedAt,
|
|
||||||
readCount: a.readCount,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}, [router, pushIfAuthedElseLogin]);
|
|
||||||
|
|
||||||
const [items, setItems] = React.useState<RecommendItem[]>(() => getFallbackItems());
|
|
||||||
|
|
||||||
// 加载训练计划数据
|
// 加载训练计划数据
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -191,15 +155,15 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 若接口返回空,也回退到打底
|
// 若接口返回空,也回退到打底
|
||||||
setItems(mapped.length > 0 ? mapped : getFallbackItems());
|
setItems(mapped.length > 0 ? mapped : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('fetchRecommendations error', e);
|
console.error('fetchRecommendations error', e);
|
||||||
setItems(getFallbackItems());
|
setItems([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
return () => { canceled = true; };
|
return () => { canceled = true; };
|
||||||
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
|
}, [isLoggedIn, pushIfAuthedElseLogin]);
|
||||||
|
|
||||||
// 处理点击训练计划卡片,跳转到锻炼tab
|
// 处理点击训练计划卡片,跳转到锻炼tab
|
||||||
const handlePlanCardPress = () => {
|
const handlePlanCardPress = () => {
|
||||||
@@ -335,7 +299,7 @@ export default function HomeScreen() {
|
|||||||
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
||||||
|
|
||||||
<View style={styles.planList}>
|
<View style={styles.planList}>
|
||||||
{items.map((item) => {
|
{items?.map((item) => {
|
||||||
if (item.type === 'article') {
|
if (item.type === 'article') {
|
||||||
return (
|
return (
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||||
import { BMICard } from '@/components/BMICard';
|
import { BMICard } from '@/components/BMICard';
|
||||||
import { CircularRing } from '@/components/CircularRing';
|
import { CircularRing } from '@/components/CircularRing';
|
||||||
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
import { ProgressBar } from '@/components/ProgressBar';
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useRouter } from 'expo-router';
|
import dayjs from 'dayjs';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
@@ -25,12 +28,13 @@ import {
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const router = useRouter();
|
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
|
|
||||||
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
// 使用 dayjs:当月日期与默认选中“今天”
|
// 使用 dayjs:当月日期与默认选中“今天”
|
||||||
const days = getMonthDaysZh();
|
const days = getMonthDaysZh();
|
||||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
@@ -67,12 +71,17 @@ export default function ExploreScreen() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||||
const [animToken, setAnimToken] = useState(0);
|
const [animToken, setAnimToken] = useState(0);
|
||||||
const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80%
|
const [trainingProgress, setTrainingProgress] = useState(0); // 暂定静态80%
|
||||||
|
|
||||||
|
// 营养数据状态
|
||||||
|
const [nutritionSummary, setNutritionSummary] = useState<NutritionSummary | null>(null);
|
||||||
|
const [isNutritionLoading, setIsNutritionLoading] = useState(false);
|
||||||
|
|
||||||
// 记录最近一次请求的“日期键”,避免旧请求覆盖新结果
|
// 记录最近一次请求的“日期键”,避免旧请求覆盖新结果
|
||||||
const latestRequestKeyRef = useRef<string | null>(null);
|
const latestRequestKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const getDateKey = (d: Date) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||||
|
|
||||||
|
|
||||||
const loadHealthData = async (targetDate?: Date) => {
|
const loadHealthData = async (targetDate?: Date) => {
|
||||||
try {
|
try {
|
||||||
@@ -112,11 +121,44 @@ export default function ExploreScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 加载营养数据
|
||||||
|
const loadNutritionData = async (targetDate?: Date) => {
|
||||||
|
try {
|
||||||
|
setIsNutritionLoading(true);
|
||||||
|
|
||||||
|
// 若未显式传入日期,按当前选中索引推导日期
|
||||||
|
const derivedDate = targetDate ?? days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
|
|
||||||
|
console.log('加载营养数据...', derivedDate);
|
||||||
|
const data = await getDietRecords({
|
||||||
|
startDate: dayjs(derivedDate).startOf('day').toISOString(),
|
||||||
|
endDate: dayjs(derivedDate).endOf('day').toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.records.length > 0) {
|
||||||
|
const summary = calculateNutritionSummary(data.records);
|
||||||
|
setNutritionSummary(summary);
|
||||||
|
} else {
|
||||||
|
setNutritionSummary(null);
|
||||||
|
}
|
||||||
|
console.log('营养数据加载完成:', data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('营养数据加载失败:', error);
|
||||||
|
setNutritionSummary(null);
|
||||||
|
} finally {
|
||||||
|
setIsNutritionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||||||
loadHealthData();
|
loadHealthData();
|
||||||
}, [selectedIndex])
|
if (isLoggedIn) {
|
||||||
|
loadNutritionData();
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
// 日期点击时,加载对应日期数据
|
// 日期点击时,加载对应日期数据
|
||||||
@@ -126,6 +168,9 @@ export default function ExploreScreen() {
|
|||||||
const target = days[index]?.date?.toDate();
|
const target = days[index]?.date?.toDate();
|
||||||
if (target) {
|
if (target) {
|
||||||
loadHealthData(target);
|
loadHealthData(target);
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loadNutritionData(target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,10 +183,10 @@ export default function ExploreScreen() {
|
|||||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 体重历史记录卡片 */}
|
{/* 体重历史记录卡片 */}
|
||||||
<Text style={styles.sectionTitle}>健康数据</Text>
|
<Text style={styles.sectionTitle}>健康数据</Text>
|
||||||
<WeightHistoryCard />
|
<WeightHistoryCard />
|
||||||
|
|
||||||
{/* 标题与日期选择 */}
|
{/* 标题与日期选择 */}
|
||||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -169,6 +214,12 @@ export default function ExploreScreen() {
|
|||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 营养摄入雷达图卡片 */}
|
||||||
|
<NutritionRadarCard
|
||||||
|
nutritionSummary={nutritionSummary}
|
||||||
|
isLoading={isNutritionLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
|
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
|
||||||
<View style={styles.metricsRow}>
|
<View style={styles.metricsRow}>
|
||||||
<View style={[styles.trainingCard, styles.metricsLeft]}>
|
<View style={[styles.trainingCard, styles.metricsLeft]}>
|
||||||
@@ -230,7 +281,8 @@ export default function ExploreScreen() {
|
|||||||
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
|
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
@@ -25,7 +26,7 @@ export default function SplashScreen() {
|
|||||||
// router.replace('/onboarding');
|
// router.replace('/onboarding');
|
||||||
// }
|
// }
|
||||||
// setIsLoading(false);
|
// setIsLoading(false);
|
||||||
router.replace('/(tabs)');
|
router.replace(ROUTES.TAB_COACH);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('检查引导状态失败:', error);
|
console.error('检查引导状态失败:', error);
|
||||||
// 如果出现错误,默认显示引导页面
|
// 如果出现错误,默认显示引导页面
|
||||||
|
|||||||
158
components/NutritionRadarCard.tsx
Normal file
158
components/NutritionRadarCard.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { NutritionSummary } from '@/services/dietRecords';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { RadarCategory, RadarChart } from './RadarChart';
|
||||||
|
|
||||||
|
export type NutritionRadarCardProps = {
|
||||||
|
nutritionSummary: NutritionSummary | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 营养维度定义
|
||||||
|
const NUTRITION_DIMENSIONS: RadarCategory[] = [
|
||||||
|
{ key: 'calories', label: '热量' },
|
||||||
|
{ key: 'protein', label: '蛋白质' },
|
||||||
|
{ key: 'carbohydrate', label: '碳水' },
|
||||||
|
{ key: 'fat', label: '脂肪' },
|
||||||
|
{ key: 'fiber', label: '膳食纤维' },
|
||||||
|
{ key: 'sodium', label: '钠含量' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function NutritionRadarCard({ nutritionSummary, isLoading = false }: NutritionRadarCardProps) {
|
||||||
|
const radarValues = useMemo(() => {
|
||||||
|
// 基于推荐日摄入量计算分数
|
||||||
|
const recommendations = {
|
||||||
|
calories: 2000, // 卡路里
|
||||||
|
protein: 50, // 蛋白质(g)
|
||||||
|
carbohydrate: 300, // 碳水化合物(g)
|
||||||
|
fat: 65, // 脂肪(g)
|
||||||
|
fiber: 25, // 膳食纤维(g)
|
||||||
|
sodium: 2300, // 钠(mg)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!nutritionSummary) return [0, 0, 0, 0, 0, 0];
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.min(5, (nutritionSummary.totalCalories / recommendations.calories) * 5),
|
||||||
|
Math.min(5, (nutritionSummary.totalProtein / recommendations.protein) * 5),
|
||||||
|
Math.min(5, (nutritionSummary.totalCarbohydrate / recommendations.carbohydrate) * 5),
|
||||||
|
Math.min(5, (nutritionSummary.totalFat / recommendations.fat) * 5),
|
||||||
|
Math.min(5, (nutritionSummary.totalFiber / recommendations.fiber) * 5),
|
||||||
|
Math.min(5, Math.max(0, 5 - (nutritionSummary.totalSodium / recommendations.sodium) * 5)), // 钠含量越低越好
|
||||||
|
];
|
||||||
|
}, [nutritionSummary]);
|
||||||
|
|
||||||
|
const nutritionStats = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
|
||||||
|
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
||||||
|
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
||||||
|
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
||||||
|
{ label: '膳食纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
||||||
|
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
||||||
|
];
|
||||||
|
}, [nutritionSummary]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
<View style={styles.radarContainer}>
|
||||||
|
<RadarChart
|
||||||
|
categories={NUTRITION_DIMENSIONS}
|
||||||
|
values={radarValues}
|
||||||
|
size={100}
|
||||||
|
maxValue={5}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
{nutritionStats.map((stat, index) => (
|
||||||
|
<View key={stat.label} style={styles.statItem}>
|
||||||
|
<View style={[styles.statDot, { backgroundColor: stat.color }]} />
|
||||||
|
<Text style={styles.statLabel}>{stat.label}</Text>
|
||||||
|
<Text style={styles.statValue}>{stat.value}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 18,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
radarContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 20,
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '48%',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
statDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9AA3AE',
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#192126',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#9AA3AE',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { fetchWeightHistory } from '@/store/userSlice';
|
import { fetchWeightHistory } from '@/store/userSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -26,13 +27,14 @@ type WeightHistoryItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function WeightHistoryCard() {
|
export function WeightHistoryCard() {
|
||||||
const router = useRouter();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showChart, setShowChart] = useState(false);
|
const [showChart, setShowChart] = useState(false);
|
||||||
|
|
||||||
|
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||||
|
|
||||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,7 +55,7 @@ export function WeightHistoryCard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToCoach = () => {
|
const navigateToCoach = () => {
|
||||||
router.push('/(tabs)/coach');
|
pushIfAuthedElseLogin(ROUTES.TAB_COACH);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果正在加载,显示加载状态
|
// 如果正在加载,显示加载状态
|
||||||
@@ -83,22 +85,19 @@ export function WeightHistoryCard() {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.cardTitle}>体重记录</Text>
|
<Text style={styles.cardTitle}>体重记录</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.emptyContent}>
|
<View style={styles.emptyContent}>
|
||||||
<View style={styles.emptyIconContainer}>
|
|
||||||
<Ionicons name="scale-outline" size={32} color="#BBF246" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.emptyTitle}>开始记录你的体重变化</Text>
|
<Text style={styles.emptyTitle}>开始记录你的体重变化</Text>
|
||||||
<Text style={styles.emptyDescription}>
|
<Text style={styles.emptyDescription}>
|
||||||
记录体重变化,追踪你的健康进展
|
记录体重变化,追踪你的健康进展
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.recordButton}
|
style={styles.recordButton}
|
||||||
onPress={navigateToCoach}
|
onPress={navigateToCoach}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Ionicons name="add" size={18} color="#FFFFFF" />
|
<Ionicons name="add" size={18} color="#192126" />
|
||||||
<Text style={styles.recordButtonText}>去记录体重</Text>
|
<Text style={styles.recordButtonText}>记录</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -119,13 +118,13 @@ export function WeightHistoryCard() {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.cardTitle}>体重记录</Text>
|
<Text style={styles.cardTitle}>体重记录</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.emptyContent}>
|
<View style={styles.emptyContent}>
|
||||||
<Text style={styles.emptyDescription}>
|
<Text style={styles.emptyDescription}>
|
||||||
暂无体重记录,点击下方按钮开始记录
|
暂无体重记录,点击下方按钮开始记录
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.recordButton}
|
style={styles.recordButton}
|
||||||
onPress={navigateToCoach}
|
onPress={navigateToCoach}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
@@ -158,8 +157,8 @@ export function WeightHistoryCard() {
|
|||||||
}).join(' ');
|
}).join(' ');
|
||||||
|
|
||||||
// 如果只有一个数据点,显示为水平线
|
// 如果只有一个数据点,显示为水平线
|
||||||
const singlePointPath = points.length === 1 ?
|
const singlePointPath = points.length === 1 ?
|
||||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
||||||
pathData;
|
pathData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,19 +169,19 @@ export function WeightHistoryCard() {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.cardTitle}>体重记录</Text>
|
<Text style={styles.cardTitle}>体重记录</Text>
|
||||||
<View style={styles.headerButtons}>
|
<View style={styles.headerButtons}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.chartToggleButton}
|
style={styles.chartToggleButton}
|
||||||
onPress={() => setShowChart(!showChart)}
|
onPress={() => setShowChart(!showChart)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={showChart ? "chevron-up" : "chevron-down"}
|
name={showChart ? "chevron-up" : "chevron-down"}
|
||||||
size={16}
|
size={16}
|
||||||
color="#BBF246"
|
color="#BBF246"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.addButton}
|
style={styles.addButton}
|
||||||
onPress={navigateToCoach}
|
onPress={navigateToCoach}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
@@ -210,8 +209,8 @@ export function WeightHistoryCard() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.viewTrendButton}
|
style={styles.viewTrendButton}
|
||||||
onPress={() => setShowChart(true)}
|
onPress={() => setShowChart(true)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
@@ -226,100 +225,100 @@ export function WeightHistoryCard() {
|
|||||||
<View style={styles.chartContainer}>
|
<View style={styles.chartContainer}>
|
||||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||||
{/* 背景网格线 */}
|
{/* 背景网格线 */}
|
||||||
{[0, 1, 2, 3, 4].map(i => (
|
{[0, 1, 2, 3, 4].map(i => (
|
||||||
<Line
|
<Line
|
||||||
key={`grid-${i}`}
|
key={`grid-${i}`}
|
||||||
x1={PADDING}
|
x1={PADDING}
|
||||||
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||||
x2={CHART_WIDTH - PADDING}
|
x2={CHART_WIDTH - PADDING}
|
||||||
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||||
stroke="#F0F0F0"
|
stroke="#F0F0F0"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 折线 */}
|
||||||
|
<Path
|
||||||
|
d={singlePointPath}
|
||||||
|
stroke="#BBF246"
|
||||||
|
strokeWidth={3}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 折线 */}
|
{/* 数据点和标签 */}
|
||||||
<Path
|
{points.map((point, index) => {
|
||||||
d={singlePointPath}
|
const isLastPoint = index === points.length - 1;
|
||||||
stroke="#BBF246"
|
const isFirstPoint = index === 0;
|
||||||
strokeWidth={3}
|
const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1;
|
||||||
fill="none"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 数据点和标签 */}
|
return (
|
||||||
{points.map((point, index) => {
|
<React.Fragment key={index}>
|
||||||
const isLastPoint = index === points.length - 1;
|
<Circle
|
||||||
const isFirstPoint = index === 0;
|
cx={point.x}
|
||||||
const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1;
|
cy={point.y}
|
||||||
|
r={isLastPoint ? 6 : 4}
|
||||||
return (
|
fill="#BBF246"
|
||||||
<React.Fragment key={index}>
|
stroke="#FFFFFF"
|
||||||
<Circle
|
strokeWidth={2}
|
||||||
cx={point.x}
|
/>
|
||||||
cy={point.y}
|
{/* 体重标签 - 只在关键点显示 */}
|
||||||
r={isLastPoint ? 6 : 4}
|
{showLabel && (
|
||||||
fill="#BBF246"
|
<>
|
||||||
stroke="#FFFFFF"
|
<Circle
|
||||||
strokeWidth={2}
|
cx={point.x}
|
||||||
/>
|
cy={point.y - 15}
|
||||||
{/* 体重标签 - 只在关键点显示 */}
|
r={10}
|
||||||
{showLabel && (
|
fill="rgba(255,255,255,0.9)"
|
||||||
<>
|
stroke="#BBF246"
|
||||||
<Circle
|
strokeWidth={1}
|
||||||
cx={point.x}
|
/>
|
||||||
cy={point.y - 15}
|
<SvgText
|
||||||
r={10}
|
x={point.x}
|
||||||
fill="rgba(255,255,255,0.9)"
|
y={point.y - 12}
|
||||||
stroke="#BBF246"
|
fontSize="9"
|
||||||
strokeWidth={1}
|
fill="#192126"
|
||||||
/>
|
textAnchor="middle"
|
||||||
<SvgText
|
>
|
||||||
x={point.x}
|
{point.weight}
|
||||||
y={point.y - 12}
|
</SvgText>
|
||||||
fontSize="9"
|
</>
|
||||||
fill="#192126"
|
)}
|
||||||
textAnchor="middle"
|
</React.Fragment>
|
||||||
>
|
);
|
||||||
{point.weight}
|
})}
|
||||||
</SvgText>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
|
|
||||||
</Svg>
|
</Svg>
|
||||||
|
|
||||||
{/* 图表底部信息 */}
|
{/* 图表底部信息 */}
|
||||||
<View style={styles.chartInfo}>
|
<View style={styles.chartInfo}>
|
||||||
<View style={styles.infoItem}>
|
<View style={styles.infoItem}>
|
||||||
<Text style={styles.infoLabel}>当前体重</Text>
|
<Text style={styles.infoLabel}>当前体重</Text>
|
||||||
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
|
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoLabel}>记录天数</Text>
|
||||||
|
<Text style={styles.infoValue}>{sortedHistory.length}天</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoLabel}>变化范围</Text>
|
||||||
|
<Text style={styles.infoValue}>
|
||||||
|
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>记录天数</Text>
|
{/* 最近记录时间 */}
|
||||||
<Text style={styles.infoValue}>{sortedHistory.length}天</Text>
|
{sortedHistory.length > 0 && (
|
||||||
</View>
|
<Text style={styles.lastRecordText}>
|
||||||
<View style={styles.infoItem}>
|
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
||||||
<Text style={styles.infoLabel}>变化范围</Text>
|
|
||||||
<Text style={styles.infoValue}>
|
|
||||||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
{/* 最近记录时间 */}
|
</View>
|
||||||
{sortedHistory.length > 0 && (
|
|
||||||
<Text style={styles.lastRecordText}>
|
|
||||||
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,16 +377,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 20,
|
|
||||||
},
|
|
||||||
emptyIconContainer: {
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
borderRadius: 30,
|
|
||||||
backgroundColor: '#F0F8E0',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
},
|
||||||
emptyTitle: {
|
emptyTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import dayjs from 'dayjs';
|
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
export type Article = {
|
export type Article = {
|
||||||
@@ -10,33 +9,6 @@ export type Article = {
|
|||||||
readCount: number;
|
readCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const demoArticles: Article[] = [
|
|
||||||
{
|
|
||||||
id: 'intro-pilates-posture',
|
|
||||||
title: '新手入门:普拉提核心与体态的关系',
|
|
||||||
coverImage: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
|
||||||
publishedAt: dayjs().subtract(2, 'day').toISOString(),
|
|
||||||
readCount: 1268,
|
|
||||||
htmlContent: `
|
|
||||||
<h2>为什么核心很重要?</h2>
|
|
||||||
<p>核心是维持良好体态与动作稳定的关键。普拉提通过强调呼吸与深层肌群激活,帮助你在<em>日常站立、坐姿与训练</em>中保持更好的身体对齐。</p>
|
|
||||||
<h3>入门建议</h3>
|
|
||||||
<ol>
|
|
||||||
<li>从呼吸开始:尝试<strong>胸廓外扩</strong>而非耸肩。</li>
|
|
||||||
<li>慢而可控:注意动作过程中的连贯与专注。</li>
|
|
||||||
<li>记录变化:每周拍照或在应用中记录体态变化。</li>
|
|
||||||
</ol>
|
|
||||||
<p>更多实操可在本应用的「AI体态评估」中获取个性化建议。</p>
|
|
||||||
<img src="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/ImageCheck.jpeg" alt="pilates-illustration" />
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function listRecommendedArticles(): Article[] {
|
|
||||||
// 实际项目中可替换为 API 请求
|
|
||||||
return demoArticles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArticleById(id: string): Promise<Article | undefined> {
|
export async function getArticleById(id: string): Promise<Article | undefined> {
|
||||||
return api.get<Article>(`/articles/${id}`);
|
return api.get<Article>(`/articles/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
115
services/dietRecords.ts
Normal file
115
services/dietRecords.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack' | 'other';
|
||||||
|
export type RecordSource = 'manual' | 'vision' | 'other';
|
||||||
|
|
||||||
|
export type DietRecord = {
|
||||||
|
id: number;
|
||||||
|
mealType: MealType;
|
||||||
|
foodName: string;
|
||||||
|
foodDescription?: string;
|
||||||
|
weightGrams?: number;
|
||||||
|
portionDescription?: string;
|
||||||
|
estimatedCalories?: number;
|
||||||
|
proteinGrams?: number;
|
||||||
|
carbohydrateGrams?: number;
|
||||||
|
fatGrams?: number;
|
||||||
|
fiberGrams?: number;
|
||||||
|
sugarGrams?: number;
|
||||||
|
sodiumMg?: number;
|
||||||
|
additionalNutrition?: any;
|
||||||
|
source: RecordSource;
|
||||||
|
mealTime?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NutritionSummary = {
|
||||||
|
totalCalories: number;
|
||||||
|
totalProtein: number;
|
||||||
|
totalCarbohydrate: number;
|
||||||
|
totalFat: number;
|
||||||
|
totalFiber: number;
|
||||||
|
totalSugar: number;
|
||||||
|
totalSodium: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getDietRecords({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}): Promise<{
|
||||||
|
records: DietRecord[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}> {
|
||||||
|
const params = startDate && endDate ? `?startDate=${startDate}&endDate=${endDate}` : '';
|
||||||
|
return await api.get<{
|
||||||
|
records: DietRecord[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}>(`/users/diet-records${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateNutritionSummary(records: DietRecord[]): NutritionSummary {
|
||||||
|
if (records?.length === 0) {
|
||||||
|
return {
|
||||||
|
totalCalories: 0,
|
||||||
|
totalProtein: 0,
|
||||||
|
totalCarbohydrate: 0,
|
||||||
|
totalFat: 0,
|
||||||
|
totalFiber: 0,
|
||||||
|
totalSugar: 0,
|
||||||
|
totalSodium: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.reduce(
|
||||||
|
(summary, record) => ({
|
||||||
|
totalCalories: summary.totalCalories + (record.estimatedCalories || 0),
|
||||||
|
totalProtein: summary.totalProtein + (record.proteinGrams || 0),
|
||||||
|
totalCarbohydrate: summary.totalCarbohydrate + (record.carbohydrateGrams || 0),
|
||||||
|
totalFat: summary.totalFat + (record.fatGrams || 0),
|
||||||
|
totalFiber: summary.totalFiber + (record.fiberGrams || 0),
|
||||||
|
totalSugar: summary.totalSugar + (record.sugarGrams || 0),
|
||||||
|
totalSodium: summary.totalSodium + (record.sodiumMg || 0),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
totalCalories: 0,
|
||||||
|
totalProtein: 0,
|
||||||
|
totalCarbohydrate: 0,
|
||||||
|
totalFat: 0,
|
||||||
|
totalFiber: 0,
|
||||||
|
totalSugar: 0,
|
||||||
|
totalSodium: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将营养数据转换为雷达图数据(0-5分制)
|
||||||
|
export function convertToRadarData(summary: NutritionSummary): number[] {
|
||||||
|
// 基于推荐日摄入量计算分数
|
||||||
|
const recommendations = {
|
||||||
|
calories: 2000, // 卡路里
|
||||||
|
protein: 50, // 蛋白质(g)
|
||||||
|
carbohydrate: 300, // 碳水化合物(g)
|
||||||
|
fat: 65, // 脂肪(g)
|
||||||
|
fiber: 25, // 膳食纤维(g)
|
||||||
|
sodium: 2300, // 钠(mg)
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.min(5, (summary.totalCalories / recommendations.calories) * 5),
|
||||||
|
Math.min(5, (summary.totalProtein / recommendations.protein) * 5),
|
||||||
|
Math.min(5, (summary.totalCarbohydrate / recommendations.carbohydrate) * 5),
|
||||||
|
Math.min(5, (summary.totalFat / recommendations.fat) * 5),
|
||||||
|
Math.min(5, (summary.totalFiber / recommendations.fiber) * 5),
|
||||||
|
Math.min(5, Math.max(0, 5 - (summary.totalSodium / recommendations.sodium) * 5)), // 钠含量越低越好
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user