feat(nutrition): 添加营养成分表拍照分析功能

新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息:
- 创建成分表分析页面,支持拍照/选择图片和结果展示
- 集成新的营养成分分析API,支持图片上传和流式分析
- 在营养雷达卡片中添加成分表分析入口
- 更新应用版本至1.0.19
This commit is contained in:
richarjiang
2025-10-16 12:16:08 +08:00
parent bef7d645a8
commit 5013464a2c
9 changed files with 1177 additions and 13 deletions

View File

@@ -6,5 +6,7 @@
- 遇到比较复杂的页面,尽量使用可以复用的组件 - 遇到比较复杂的页面,尽量使用可以复用的组件
- 不要尝试使用 `npm run ios` 命令 - 不要尝试使用 `npm run ios` 命令
- 优先使用 Liquid Glass 风格组件 - 优先使用 Liquid Glass 风格组件
- 注重代码的可读性,尽量增加注释
- 不要随意新增 md 文档

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Out Live", "name": "Out Live",
"slug": "digital-pilates", "slug": "digital-pilates",
"version": "1.0.18", "version": "1.0.19",
"orientation": "portrait", "orientation": "portrait",
"scheme": "digitalpilates", "scheme": "digitalpilates",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",

View File

@@ -0,0 +1,843 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
analyzeNutritionImage,
analyzeNutritionLabelStream,
convertNewApiResultToOldFormat,
type NutritionAnalysisResponse,
type NutritionItem,
type NutritionLabelAnalysisResult
} from '@/services/nutritionLabelAnalysis';
import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
BackHandler,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
export default function NutritionLabelAnalysisScreen() {
const safeAreaTop = useSafeAreaTop();
const router = useRouter();
const { upload, uploading: uploadingToCos, progress: uploadProgress } = useCosUpload({
prefix: 'nutrition-labels'
});
const [imageUri, setImageUri] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [showImagePreview, setShowImagePreview] = useState(false);
const [analysisText, setAnalysisText] = useState<string>('');
const [analysisResult, setAnalysisResult] = useState<NutritionLabelAnalysisResult | null>(null);
const [newAnalysisResult, setNewAnalysisResult] = useState<NutritionAnalysisResponse | null>(null);
const [isUploading, setIsUploading] = useState(false);
// 流式请求相关引用
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
const scrollViewRef = useRef<ScrollView>(null);
// 处理Android返回键关闭图片预览
React.useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showImagePreview) {
setShowImagePreview(false);
return true; // 阻止默认返回行为
}
return false;
});
return () => backHandler.remove();
}, [showImagePreview]);
// 组件卸载时清理流式请求
React.useEffect(() => {
return () => {
try {
if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
} catch (error) {
console.warn('[NUTRITION_ANALYSIS] Error aborting stream on unmount:', error);
}
};
}, []);
// 请求相机权限
const requestCameraPermission = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限不足', '需要相机权限才能拍摄成分表');
return false;
}
return true;
};
// 拍照
const takePhoto = async () => {
const hasPermission = await requestCameraPermission();
if (!hasPermission) return;
triggerLightHaptic();
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setAnalysisText(''); // 清除之前的分析文本
setAnalysisResult(null); // 清除之前的分析结果
setNewAnalysisResult(null); // 清除新的分析结果
}
};
// 从相册选择
const pickImage = async () => {
triggerLightHaptic();
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setAnalysisText(''); // 清除之前的分析文本
setAnalysisResult(null); // 清除之前的分析结果
setNewAnalysisResult(null); // 清除新的分析结果
}
};
// 取消当前分析
const cancelAnalysis = useCallback(() => {
try {
console.log('[NUTRITION_ANALYSIS] User cancelled analysis');
// 中断网络请求
if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
// 清理状态
setIsAnalyzing(false);
setAnalysisText('');
// 触觉反馈
triggerLightHaptic();
} catch (error) {
console.warn('[NUTRITION_ANALYSIS] Error cancelling analysis:', error);
}
}, []);
// 开始流式分析图片
const startAnalysis = useCallback(async (uri: string) => {
if (isAnalyzing) return;
setIsAnalyzing(true);
setAnalysisText('');
setAnalysisResult(null);
// 延迟滚动到分析结果区域给UI一些时间更新
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 350, animated: true });
}, 300);
let isFirstChunk = true;
try {
await analyzeNutritionLabelStream(
{ imageUri: uri },
{
onChunk: (chunk: string) => {
setAnalysisText(prev => {
const newText = prev + chunk;
// 在接收到第一个文本块时滚动到分析结果区域
if (isFirstChunk && newText.length > 0) {
isFirstChunk = false;
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
}
return newText;
});
},
onEnd: () => {
setIsAnalyzing(false);
streamAbortRef.current = null;
console.log('[NUTRITION_ANALYSIS] Analysis completed');
},
onError: (error: any) => {
console.error('[NUTRITION_ANALYSIS] Analysis failed:', error);
setIsAnalyzing(false);
setAnalysisText('');
streamAbortRef.current = null;
// 如果是用户主动取消,不显示错误提示
if (error?.name !== 'AbortError' && !error?.message?.includes('abort')) {
Alert.alert('分析失败', '无法识别成分表,请尝试拍摄更清晰的照片');
}
}
}
);
} catch (error) {
console.error('[NUTRITION_ANALYSIS] Analysis error:', error);
setIsAnalyzing(false);
setAnalysisText('');
Alert.alert('分析失败', '无法识别成分表,请尝试拍摄更清晰的照片');
}
}, [isAnalyzing]);
// 新的分析函数先上传图片到COS然后调用新API
const startNewAnalysis = useCallback(async (uri: string) => {
if (isAnalyzing || isUploading) return;
setIsUploading(true);
setAnalysisText('');
setAnalysisResult(null);
setNewAnalysisResult(null);
// 延迟滚动到分析结果区域给UI一些时间更新
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 350, animated: true });
}, 300);
try {
// 第一步上传图片到COS
console.log('[NUTRITION_ANALYSIS] 开始上传图片到COS...');
const uploadResult = await upload(uri);
console.log('[NUTRITION_ANALYSIS] 图片上传成功:', uploadResult.url);
setIsUploading(false);
setIsAnalyzing(true);
// 第二步调用新的营养成分分析API
console.log('[NUTRITION_ANALYSIS] 开始调用营养成分分析API...');
const analysisResponse = await analyzeNutritionImage({
imageUrl: uploadResult.url
});
console.log('[NUTRITION_ANALYSIS] API响应:', analysisResponse);
if (analysisResponse.success && analysisResponse.data) {
// 转换为旧格式以便与现有UI兼容
const oldFormatResult = convertNewApiResultToOldFormat(analysisResponse, uploadResult.url);
if (oldFormatResult) {
setAnalysisResult(oldFormatResult);
setNewAnalysisResult(analysisResponse);
// 生成分析文本
const analysisText = analysisResponse.data
.map((item: NutritionItem) => `**${item.name}**: ${item.value}\n${item.analysis}`)
.join('\n\n');
setAnalysisText(analysisText);
} else {
throw new Error('无法解析API返回结果');
}
} else {
throw new Error(analysisResponse.message || '分析失败');
}
} catch (error: any) {
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
setIsUploading(false);
setIsAnalyzing(false);
setAnalysisText('');
// 显示错误提示
Alert.alert(
'分析失败',
error.message || '无法识别成分表,请尝试拍摄更清晰的照片'
);
} finally {
setIsUploading(false);
setIsAnalyzing(false);
}
}, [isAnalyzing, isUploading, upload]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title="成分表分析"
onBack={() => router.back()}
transparent={true}
/>
<ScrollView
ref={scrollViewRef}
style={styles.scrollContainer}
contentContainerStyle={{
paddingTop: safeAreaTop
}}
showsVerticalScrollIndicator={false}
>
{/* 图片区域 */}
<View style={styles.imageContainer}>
{imageUri ? (
<View>
<TouchableOpacity
onPress={() => setShowImagePreview(true)}
activeOpacity={0.9}
>
<Image
source={{ uri: imageUri }}
style={styles.foodImage}
cachePolicy={'memory-disk'}
/>
{/* 预览提示图标 */}
<View style={styles.previewHint}>
<Ionicons name="expand-outline" size={20} color="#FFF" />
</View>
</TouchableOpacity>
{/* 开始分析按钮 */}
{!isAnalyzing && !isUploading && !analysisText && (
<TouchableOpacity
style={styles.analyzeButton}
onPress={() => startNewAnalysis(imageUri)}
activeOpacity={0.8}
>
<Ionicons name="search-outline" size={20} color="#FFF" />
<Text style={styles.analyzeButtonText}></Text>
</TouchableOpacity>
)}
{/* 删除图片按钮 */}
<TouchableOpacity
style={styles.deleteImageButton}
onPress={() => {
setImageUri(null);
setAnalysisText('');
setAnalysisResult(null);
setNewAnalysisResult(null);
triggerLightHaptic();
}}
activeOpacity={0.8}
>
<Ionicons name="trash-outline" size={16} color="#FFF" />
</TouchableOpacity>
</View>
) : (
<View style={styles.placeholderContainer}>
<View style={styles.placeholderContent}>
<Ionicons name="document-text-outline" size={48} color="#666" />
<Text style={styles.placeholderText}></Text>
</View>
{/* 操作按钮区域 */}
<View style={styles.imageActionButtonsContainer}>
<TouchableOpacity
style={styles.imageActionButton}
onPress={takePhoto}
activeOpacity={0.8}
>
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} />
<Text style={styles.imageActionButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.imageActionButton, styles.imageActionButtonSecondary]}
onPress={pickImage}
activeOpacity={0.8}
>
<Ionicons name="image-outline" size={20} color={Colors.light.primary} />
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}></Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
{/* 流式分析文本区域 */}
{analysisText && (
<View style={styles.analysisTextCard}>
<View style={styles.analysisTextHeader}>
<Text style={styles.analysisTextTitle}></Text>
{isAnalyzing && (
<TouchableOpacity
style={styles.cancelAnalysisButton}
onPress={cancelAnalysis}
activeOpacity={0.8}
>
<Ionicons name="close-outline" size={16} color="#FF4444" />
<Text style={styles.cancelAnalysisText}></Text>
</TouchableOpacity>
)}
</View>
<Text style={styles.analysisTextContent}>{analysisText}</Text>
{isAnalyzing && (
<View style={styles.streamingIndicator}>
<ActivityIndicator size="small" color={Colors.light.primary} />
<Text style={styles.streamingText}>...</Text>
</View>
)}
</View>
)}
{/* 新API营养成分详细分析结果 */}
{newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && (
<View style={styles.nutritionDetailsCard}>
<View style={styles.nutritionDetailsHeader}>
<Text style={styles.nutritionDetailsTitle}></Text>
</View>
{newAnalysisResult.data.map((item, index) => (
<View key={item.key || index} style={styles.nutritionDetailItem}>
<View style={styles.nutritionDetailHeader}>
<Text style={styles.nutritionDetailName}>{item.name}</Text>
<Text style={styles.nutritionDetailValue}>{item.value}</Text>
</View>
<Text style={styles.nutritionDetailAnalysis}>{item.analysis}</Text>
</View>
))}
</View>
)}
{/* 上传状态 */}
{isUploading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>
... {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
</Text>
</View>
)}
{/* 加载状态 */}
{isAnalyzing && !analysisText && !isUploading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
)}
</ScrollView>
{/* 图片预览 */}
<ImageViewing
images={imageUri ? [{ uri: imageUri }] : []}
imageIndex={0}
visible={showImagePreview}
onRequestClose={() => setShowImagePreview(false)}
swipeToCloseEnabled={true}
doubleTapToZoomEnabled={true}
HeaderComponent={() => (
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')}
</Text>
</View>
)}
FooterComponent={() => (
<View style={styles.imageViewerFooter}>
<TouchableOpacity
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
</TouchableOpacity>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5e5fbff',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollContainer: {
flex: 1,
},
imageContainer: {
position: 'relative',
height: 300,
marginHorizontal: 16,
marginTop: 16,
borderRadius: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
foodImage: {
width: '100%',
height: '100%',
borderRadius: 20,
},
previewHint: {
position: 'absolute',
top: 16,
right: 16,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 20,
padding: 8,
},
deleteImageButton: {
position: 'absolute',
top: 16,
left: 16,
backgroundColor: 'rgba(255, 59, 48, 0.9)',
borderRadius: 20,
padding: 8,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
placeholderContainer: {
width: '100%',
height: '100%',
backgroundColor: '#F8F9FA',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
borderWidth: 2,
borderColor: '#E9ECEF',
borderStyle: 'dashed',
},
placeholderContent: {
alignItems: 'center',
marginBottom: 40,
},
placeholderText: {
fontSize: 16,
color: '#666',
fontWeight: '500',
marginTop: 8,
},
imageActionButtonsContainer: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 20,
},
imageActionButton: {
flex: 1,
backgroundColor: Colors.light.primary,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 16,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
shadowColor: Colors.light.primary,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
imageActionButtonSecondary: {
backgroundColor: 'transparent',
borderWidth: 1.5,
borderColor: Colors.light.primary,
shadowOpacity: 0,
elevation: 0,
},
imageActionButtonText: {
color: Colors.light.onPrimary,
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
resultCard: {
backgroundColor: Colors.light.background,
margin: 16,
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
resultHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
resultTitle: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
},
confidenceContainer: {
backgroundColor: '#E8F5E8',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
confidenceText: {
fontSize: 12,
color: '#4CAF50',
fontWeight: '500',
},
foodInfoContainer: {
marginBottom: 12,
},
foodNameLabel: {
fontSize: 14,
color: Colors.light.textSecondary,
marginBottom: 4,
},
foodNameValue: {
fontSize: 16,
color: Colors.light.text,
fontWeight: '500',
},
nutritionGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 8,
},
nutritionItem: {
width: '50%',
marginBottom: 12,
paddingRight: 8,
},
nutritionLabel: {
fontSize: 12,
color: Colors.light.textSecondary,
marginBottom: 2,
},
nutritionValue: {
fontSize: 16,
color: Colors.light.text,
fontWeight: '600',
},
loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 40,
},
loadingText: {
fontSize: 16,
color: Colors.light.textSecondary,
marginTop: 12,
},
// ImageViewing 组件样式
imageViewerHeader: {
position: 'absolute',
top: 60,
left: 20,
right: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
zIndex: 1,
},
imageViewerHeaderText: {
color: '#FFF',
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
},
imageViewerFooter: {
position: 'absolute',
bottom: 60,
left: 20,
right: 20,
alignItems: 'center',
zIndex: 1,
},
imageViewerFooterButton: {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 20,
},
imageViewerFooterButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '500',
},
// 开始分析按钮样式
analyzeButton: {
position: 'absolute',
bottom: 16,
right: 16,
backgroundColor: Colors.light.primary,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
alignItems: 'center',
flexDirection: 'row',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
analyzeButtonText: {
color: Colors.light.onPrimary,
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
// 分析文本卡片样式
analysisTextCard: {
backgroundColor: Colors.light.background,
margin: 16,
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
analysisTextHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
analysisTextTitle: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
},
cancelAnalysisButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(255,68,68,0.1)',
borderWidth: 1,
borderColor: 'rgba(255,68,68,0.3)',
},
cancelAnalysisText: {
fontSize: 12,
fontWeight: '600',
color: '#FF4444',
},
analysisTextContent: {
fontSize: 15,
lineHeight: 22,
color: Colors.light.text,
marginBottom: 12,
},
streamingIndicator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 8,
},
streamingText: {
fontSize: 14,
color: Colors.light.textSecondary,
fontStyle: 'italic',
},
// 营养成分详细分析卡片样式
nutritionDetailsCard: {
backgroundColor: Colors.light.background,
margin: 16,
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
nutritionDetailsHeader: {
marginBottom: 16,
},
nutritionDetailsTitle: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
},
nutritionDetailItem: {
marginBottom: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
nutritionDetailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
nutritionDetailName: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
flex: 1,
},
nutritionDetailValue: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.primary,
backgroundColor: 'rgba(74, 144, 226, 0.1)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
nutritionDetailAnalysis: {
fontSize: 14,
lineHeight: 20,
color: Colors.light.textSecondary,
},
});

View File

@@ -1,7 +1,7 @@
import { AnimatedNumber } from '@/components/AnimatedNumber'; import { AnimatedNumber } from '@/components/AnimatedNumber';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useActiveCalories } from '@/hooks/useActiveCalories';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useActiveCalories } from '@/hooks/useActiveCalories';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { triggerLightHaptic } from '@/utils/haptics'; import { triggerLightHaptic } from '@/utils/haptics';
@@ -296,6 +296,23 @@ export function NutritionRadarCard({
</View> </View>
<Text style={styles.foodOptionText}></Text> <Text style={styles.foodOptionText}></Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-recommend.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}></Text>
</TouchableOpacity>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -493,16 +510,17 @@ const styles = StyleSheet.create({
// 食物选项样式 // 食物选项样式
foodOptionsContainer: { foodOptionsContainer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-around', justifyContent: 'space-between',
marginTop: 12, marginTop: 12,
paddingTop: 12, paddingTop: 12,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#F1F5F9', borderTopColor: '#F1F5F9',
gap: 16, paddingHorizontal: 4,
}, },
foodOptionItem: { foodOptionItem: {
alignItems: 'center', alignItems: 'center',
flex: 1, flex: 1,
paddingHorizontal: 2,
}, },
foodOptionIcon: { foodOptionIcon: {
width: 24, width: 24,

View File

@@ -40,6 +40,7 @@ export const ROUTES = {
NUTRITION_RECORDS: '/nutrition/records', NUTRITION_RECORDS: '/nutrition/records',
FOOD_LIBRARY: '/food-library', FOOD_LIBRARY: '/food-library',
VOICE_RECORD: '/voice-record', VOICE_RECORD: '/voice-record',
NUTRITION_LABEL_ANALYSIS: '/food/nutrition-label-analysis',
// 体重记录相关路由 // 体重记录相关路由
WEIGHT_RECORDS: '/weight-records', WEIGHT_RECORDS: '/weight-records',

View File

@@ -0,0 +1,121 @@
# 成分表分析功能实现计划
## 功能概述
实现一个通过拍照识别食物包装上营养成分表的功能,用户可以拍摄食物包装上的成分表,系统会自动识别并解析营养成分信息。
## 实现步骤
### 1. 添加路由配置
`constants/Routes.ts` 中添加成分表分析的路由:
```typescript
// 营养相关路由
NUTRITION_RECORDS: '/nutrition/records',
FOOD_LIBRARY: '/food-library',
VOICE_RECORD: '/voice-record',
NUTRITION_LABEL_ANALYSIS: '/food/nutrition-label-analysis', // 新增
```
### 2. 创建成分表分析页面
创建 `app/food/nutrition-label-analysis.tsx` 页面组件,包含以下功能:
- 拍照/选择图片功能
- 图片预览
- 成分表识别结果展示
- 营养成分数据展示
- 保存到饮食记录功能
### 3. 修改NutritionRadarCard组件
`components/NutritionRadarCard.tsx` 的食物选项容器中添加成分表分析选项:
```typescript
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-analysis.png')} // 需要添加新图标
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}>成分表分析</Text>
</TouchableOpacity>
```
### 4. 创建API服务
`services/` 目录下创建 `nutritionLabelAnalysis.ts` 文件包含以下API
- 图片上传和识别功能
- 成分表数据解析功能
### 5. 添加图标资源
需要在 `assets/images/icons/` 目录下添加成分表分析相关的图标,建议命名为 `icon-analysis.png``icon-nutrition-label.png`
### 6. 创建类型定义
`types/` 目录下创建或更新食物相关的类型定义,添加成分表分析相关的数据结构
## 技术实现细节
### 页面结构
1. **顶部导航栏**:包含返回按钮和标题"成分表分析"
2. **图片区域**:显示拍摄的成分表图片,支持点击预览
3. **识别结果区域**:显示识别出的营养成分数据
4. **操作区域**:包含重新拍照和保存记录按钮
### 数据结构
```typescript
export interface NutritionLabelData {
energy: number; // 能量 (kJ/千卡)
protein: number; // 蛋白质 (g)
fat: number; // 脂肪 (g)
carbohydrate: number; // 碳水化合物 (g)
sodium: number; // 钠 (mg)
fiber?: number; // 膳食纤维 (g)
sugar?: number; // 糖 (g)
servingSize?: string; // 每份量
}
export interface NutritionLabelAnalysisResult {
id: string;
imageUri: string;
nutritionData: NutritionLabelData;
confidence: number;
analyzedAt: string;
}
```
### API接口设计
```typescript
// 上传图片并识别成分表
export async function analyzeNutritionLabel(imageUri: string): Promise<NutritionLabelAnalysisResult> {
// 实现图片上传和识别逻辑
}
// 保存成分表分析结果到饮食记录
export async function saveNutritionLabelToDietRecord(
analysisResult: NutritionLabelAnalysisResult,
mealType: MealType
): Promise<DietRecord> {
// 实现保存逻辑
}
```
## UI设计考虑
1. **Liquid Glass风格**:使用毛玻璃效果和渐变背景
2. **动画效果**:添加识别过程中的加载动画和结果展示动画
3. **交互反馈**:使用触觉反馈和视觉反馈提升用户体验
4. **错误处理**:提供友好的错误提示和重试机制
## 测试计划
1. 测试拍照功能
2. 测试图片识别功能
3. 测试数据保存功能
4. 测试错误处理
5. 测试与现有功能的集成
## 后续优化
1. 支持多语言成分表识别
2. 添加成分表历史记录
3. 实现成分表数据对比功能
4. 添加成分表数据分享功能

View File

@@ -25,7 +25,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.18</string> <string>1.0.19</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -47,13 +47,9 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
signal: options.signal, signal: options.signal,
}); });
const text = await response.text(); const json = await response.json()
let json: any = null;
try { console.log('json', json);
json = text ? JSON.parse(text) : null;
} catch {
// 非 JSON 响应
}
if (!response.ok) { if (!response.ok) {
const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`; const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
@@ -63,6 +59,15 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
throw error; throw error;
} }
if (json.code !== undefined && json.code !== 0) {
const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
const error = new Error(errorMessage);
// @ts-expect-error augment
error.status = response.status;
throw error;
}
// 支持后端返回 { data: ... } 或直接返回对象 // 支持后端返回 { data: ... } 或直接返回对象
return (json && (json.data ?? json)) as T; return (json && (json.data ?? json)) as T;
} }

View File

@@ -0,0 +1,174 @@
import { api, postTextStream, type TextStreamCallbacks } from '@/services/api';
import type { CreateDietRecordDto, MealType } from './dietRecords';
export interface NutritionLabelData {
energy: number; // 能量 (kJ/千卡)
protein: number; // 蛋白质 (g)
fat: number; // 脂肪 (g)
carbohydrate: number; // 碳水化合物 (g)
sodium: number; // 钠 (mg)
fiber?: number; // 膳食纤维 (g)
sugar?: number; // 糖 (g)
servingSize?: string; // 每份量
}
export interface NutritionLabelAnalysisResult {
id: string;
imageUri: string;
nutritionData: NutritionLabelData;
confidence: number;
analyzedAt: string;
foodName?: string; // 识别出的食物名称
brand?: string; // 品牌
}
export interface NutritionLabelAnalysisRequest {
imageUri: string;
}
// 新API返回的营养成分项
export interface NutritionItem {
key: string;
name: string;
value: string;
analysis: string;
}
// 新API返回的响应格式
export interface NutritionAnalysisResponse {
success: boolean;
data: NutritionItem[];
message?: string; // 仅在失败时返回
}
// 新API请求参数
export interface NutritionAnalysisRequest {
imageUrl: string;
}
/**
* 分析营养成分表(非流式)
*/
export async function analyzeNutritionLabel(request: NutritionLabelAnalysisRequest): Promise<NutritionLabelAnalysisResult> {
return api.post<NutritionLabelAnalysisResult>('/ai-coach/nutrition-label-analysis', request);
}
/**
* 流式分析营养成分表
*/
export async function analyzeNutritionLabelStream(
request: NutritionLabelAnalysisRequest,
callbacks: TextStreamCallbacks
) {
const body = {
imageUri: request.imageUri,
stream: true
};
return postTextStream('/ai-coach/nutrition-label-analysis', body, callbacks, { timeoutMs: 120000 });
}
/**
* 保存成分表分析结果到饮食记录
*/
export async function saveNutritionLabelToDietRecord(
analysisResult: NutritionLabelAnalysisResult,
mealType: MealType
): Promise<any> {
const dietRecordData: CreateDietRecordDto = {
mealType,
foodName: analysisResult.foodName || '成分表分析食物',
foodDescription: `品牌: ${analysisResult.brand || '未知'}`,
portionDescription: analysisResult.nutritionData.servingSize || '100g',
estimatedCalories: analysisResult.nutritionData.energy,
proteinGrams: analysisResult.nutritionData.protein,
carbohydrateGrams: analysisResult.nutritionData.carbohydrate,
fatGrams: analysisResult.nutritionData.fat,
fiberGrams: analysisResult.nutritionData.fiber,
sugarGrams: analysisResult.nutritionData.sugar,
sodiumMg: analysisResult.nutritionData.sodium,
source: 'vision',
mealTime: new Date().toISOString(),
imageUrl: analysisResult.imageUri,
aiAnalysisResult: analysisResult,
};
return api.post('/diet-records', dietRecordData);
}
/**
* 分析营养成分表图片新API
* 需要先上传图片到COS获取URL然后调用此接口
*/
export async function analyzeNutritionImage(request: NutritionAnalysisRequest): Promise<NutritionAnalysisResponse> {
return api.post<NutritionAnalysisResponse>('/diet-records/analyze-nutrition-image', request);
}
/**
* 将新API的分析结果转换为旧格式以便与现有UI兼容
*/
export function convertNewApiResultToOldFormat(
newResult: NutritionAnalysisResponse,
imageUri: string
): NutritionLabelAnalysisResult | null {
if (!newResult.success || !newResult.data || newResult.data.length === 0) {
return null;
}
// 从新API结果中提取营养数据
const nutritionData: NutritionLabelData = {
energy: 0,
protein: 0,
fat: 0,
carbohydrate: 0,
sodium: 0,
fiber: 0,
sugar: 0,
};
// 查找各个营养素的值并转换为数字
newResult.data.forEach(item => {
const valueStr = item.value;
// 提取数字部分
const numericValue = parseFloat(valueStr.replace(/[^\d.]/g, ''));
switch (item.key) {
case 'energy_kcal':
// 如果是千焦,转换为千卡 (1千焦 ≈ 0.239千卡)
if (valueStr.includes('千焦')) {
nutritionData.energy = Math.round(numericValue * 0.239);
} else {
nutritionData.energy = numericValue;
}
break;
case 'protein':
nutritionData.protein = numericValue;
break;
case 'fat':
nutritionData.fat = numericValue;
break;
case 'carbohydrate':
nutritionData.carbohydrate = numericValue;
break;
case 'sodium':
nutritionData.sodium = numericValue;
break;
case 'fiber':
nutritionData.fiber = numericValue;
break;
case 'sugar':
nutritionData.sugar = numericValue;
break;
}
});
return {
id: Date.now().toString(),
imageUri,
nutritionData,
confidence: 0.9, // 新API没有提供置信度使用默认值
analyzedAt: new Date().toISOString(),
foodName: '营养成分表分析',
brand: '未知',
};
}