feat: 新增营养摄入分析卡片并优化相关页面

- 在统计页面中引入营养摄入分析卡片,展示用户的营养数据
- 更新探索页面,增加营养数据加载逻辑,确保用户体验一致性
- 移除不再使用的推荐文章逻辑,简化代码结构
- 更新路由常量,确保路径管理集中化
- 优化体重历史记录卡片,提升用户交互体验
This commit is contained in:
richarjiang
2025-08-19 10:01:26 +08:00
parent c7d7255312
commit 9aa0a692a8
7 changed files with 452 additions and 201 deletions

View File

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

View File

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

View File

@@ -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);
// 如果出现错误,默认显示引导页面 // 如果出现错误,默认显示引导页面

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

View File

@@ -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,

View File

@@ -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
View 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)), // 钠含量越低越好
];
}