feat: 新增营养摄入分析卡片并优化相关页面
- 在统计页面中引入营养摄入分析卡片,展示用户的营养数据 - 更新探索页面,增加营养数据加载逻辑,确保用户体验一致性 - 移除不再使用的推荐文章逻辑,简化代码结构 - 更新路由常量,确保路径管理集中化 - 优化体重历史记录卡片,提升用户交互体验
This commit is contained in:
@@ -6,17 +6,16 @@ import { ThemedView } from '@/components/ThemedView';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { listRecommendedArticles } from '@/services/articles';
|
||||
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
||||
import { loadPlans } from '@/store/trainingPlanSlice';
|
||||
// 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 { TrainingPlan } from '@/services/trainingPlanApi';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||
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;
|
||||
};
|
||||
|
||||
// 打底数据(接口不可用时)
|
||||
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());
|
||||
const [items, setItems] = React.useState<RecommendItem[]>();
|
||||
|
||||
// 加载训练计划数据
|
||||
React.useEffect(() => {
|
||||
@@ -191,15 +155,15 @@ export default function HomeScreen() {
|
||||
}
|
||||
}
|
||||
// 若接口返回空,也回退到打底
|
||||
setItems(mapped.length > 0 ? mapped : getFallbackItems());
|
||||
setItems(mapped.length > 0 ? mapped : []);
|
||||
} catch (e) {
|
||||
console.error('fetchRecommendations error', e);
|
||||
setItems(getFallbackItems());
|
||||
setItems([]);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { canceled = true; };
|
||||
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
|
||||
}, [isLoggedIn, pushIfAuthedElseLogin]);
|
||||
|
||||
// 处理点击训练计划卡片,跳转到锻炼tab
|
||||
const handlePlanCardPress = () => {
|
||||
@@ -335,7 +299,7 @@ export default function HomeScreen() {
|
||||
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
||||
|
||||
<View style={styles.planList}>
|
||||
{items.map((item) => {
|
||||
{items?.map((item) => {
|
||||
if (item.type === 'article') {
|
||||
return (
|
||||
<ArticleCard
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { BMICard } from '@/components/BMICard';
|
||||
import { CircularRing } from '@/components/CircularRing';
|
||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
SafeAreaView,
|
||||
@@ -25,12 +28,13 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const router = useRouter();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中“今天”
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -67,12 +71,17 @@ export default function ExploreScreen() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
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 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) => {
|
||||
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(
|
||||
React.useCallback(() => {
|
||||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||||
loadHealthData();
|
||||
}, [selectedIndex])
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData();
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 日期点击时,加载对应日期数据
|
||||
@@ -126,6 +168,9 @@ export default function ExploreScreen() {
|
||||
const target = days[index]?.date?.toDate();
|
||||
if (target) {
|
||||
loadHealthData(target);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(target);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,8 +183,8 @@ export default function ExploreScreen() {
|
||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 体重历史记录卡片 */}
|
||||
<Text style={styles.sectionTitle}>健康数据</Text>
|
||||
{/* 体重历史记录卡片 */}
|
||||
<Text style={styles.sectionTitle}>健康数据</Text>
|
||||
<WeightHistoryCard />
|
||||
|
||||
{/* 标题与日期选择 */}
|
||||
@@ -169,6 +214,12 @@ export default function ExploreScreen() {
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* 营养摄入雷达图卡片 */}
|
||||
<NutritionRadarCard
|
||||
nutritionSummary={nutritionSummary}
|
||||
isLoading={isNutritionLoading}
|
||||
/>
|
||||
|
||||
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={[styles.trainingCard, styles.metricsLeft]}>
|
||||
@@ -231,6 +282,7 @@ export default function ExploreScreen() {
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -25,7 +26,7 @@ export default function SplashScreen() {
|
||||
// router.replace('/onboarding');
|
||||
// }
|
||||
// setIsLoading(false);
|
||||
router.replace('/(tabs)');
|
||||
router.replace(ROUTES.TAB_COACH);
|
||||
} catch (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 { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { fetchWeightHistory } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
@@ -26,13 +27,14 @@ type WeightHistoryItem = {
|
||||
};
|
||||
|
||||
export function WeightHistoryCard() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showChart, setShowChart] = useState(false);
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
|
||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +55,7 @@ export function WeightHistoryCard() {
|
||||
};
|
||||
|
||||
const navigateToCoach = () => {
|
||||
router.push('/(tabs)/coach');
|
||||
pushIfAuthedElseLogin(ROUTES.TAB_COACH);
|
||||
};
|
||||
|
||||
// 如果正在加载,显示加载状态
|
||||
@@ -85,9 +87,6 @@ export function WeightHistoryCard() {
|
||||
</View>
|
||||
|
||||
<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.emptyDescription}>
|
||||
记录体重变化,追踪你的健康进展
|
||||
@@ -97,8 +96,8 @@ export function WeightHistoryCard() {
|
||||
onPress={navigateToCoach}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#FFFFFF" />
|
||||
<Text style={styles.recordButtonText}>去记录体重</Text>
|
||||
<Ionicons name="add" size={18} color="#192126" />
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -226,100 +225,100 @@ export function WeightHistoryCard() {
|
||||
<View style={styles.chartContainer}>
|
||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||
{/* 背景网格线 */}
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<Line
|
||||
key={`grid-${i}`}
|
||||
x1={PADDING}
|
||||
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
x2={CHART_WIDTH - PADDING}
|
||||
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
stroke="#F0F0F0"
|
||||
strokeWidth={1}
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<Line
|
||||
key={`grid-${i}`}
|
||||
x1={PADDING}
|
||||
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
x2={CHART_WIDTH - PADDING}
|
||||
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
stroke="#F0F0F0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 折线 */}
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke="#BBF246"
|
||||
strokeWidth={3}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 折线 */}
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke="#BBF246"
|
||||
strokeWidth={3}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* 数据点和标签 */}
|
||||
{points.map((point, index) => {
|
||||
const isLastPoint = index === points.length - 1;
|
||||
const isFirstPoint = index === 0;
|
||||
const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1;
|
||||
|
||||
{/* 数据点和标签 */}
|
||||
{points.map((point, index) => {
|
||||
const isLastPoint = index === points.length - 1;
|
||||
const isFirstPoint = index === 0;
|
||||
const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={isLastPoint ? 6 : 4}
|
||||
fill="#BBF246"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* 体重标签 - 只在关键点显示 */}
|
||||
{showLabel && (
|
||||
<>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y - 15}
|
||||
r={10}
|
||||
fill="rgba(255,255,255,0.9)"
|
||||
stroke="#BBF246"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<SvgText
|
||||
x={point.x}
|
||||
y={point.y - 12}
|
||||
fontSize="9"
|
||||
fill="#192126"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{point.weight}
|
||||
</SvgText>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={isLastPoint ? 6 : 4}
|
||||
fill="#BBF246"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* 体重标签 - 只在关键点显示 */}
|
||||
{showLabel && (
|
||||
<>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y - 15}
|
||||
r={10}
|
||||
fill="rgba(255,255,255,0.9)"
|
||||
stroke="#BBF246"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<SvgText
|
||||
x={point.x}
|
||||
y={point.y - 12}
|
||||
fontSize="9"
|
||||
fill="#192126"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{point.weight}
|
||||
</SvgText>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</Svg>
|
||||
</Svg>
|
||||
|
||||
{/* 图表底部信息 */}
|
||||
<View style={styles.chartInfo}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>当前体重</Text>
|
||||
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
|
||||
{/* 图表底部信息 */}
|
||||
<View style={styles.chartInfo}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>当前体重</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 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
|
||||
|
||||
{/* 最近记录时间 */}
|
||||
{sortedHistory.length > 0 && (
|
||||
<Text style={styles.lastRecordText}>
|
||||
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 最近记录时间 */}
|
||||
{sortedHistory.length > 0 && (
|
||||
<Text style={styles.lastRecordText}>
|
||||
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,16 +377,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#F0F8E0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 16,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from './api';
|
||||
|
||||
export type Article = {
|
||||
@@ -10,33 +9,6 @@ export type Article = {
|
||||
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> {
|
||||
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