feat(nutrition): 添加营养成分表拍照分析功能
新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息: - 创建成分表分析页面,支持拍照/选择图片和结果展示 - 集成新的营养成分分析API,支持图片上传和流式分析 - 在营养雷达卡片中添加成分表分析入口 - 更新应用版本至1.0.19
This commit is contained in:
@@ -6,5 +6,7 @@
|
||||
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
||||
- 不要尝试使用 `npm run ios` 命令
|
||||
- 优先使用 Liquid Glass 风格组件
|
||||
- 注重代码的可读性,尽量增加注释
|
||||
- 不要随意新增 md 文档
|
||||
|
||||
|
||||
|
||||
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.18",
|
||||
"version": "1.0.19",
|
||||
"orientation": "portrait",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
|
||||
843
app/food/nutrition-label-analysis.tsx
Normal file
843
app/food/nutrition-label-analysis.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useActiveCalories } from '@/hooks/useActiveCalories';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useActiveCalories } from '@/hooks/useActiveCalories';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { triggerLightHaptic } from '@/utils/haptics';
|
||||
@@ -296,6 +296,23 @@ export function NutritionRadarCard({
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>一句话记录</Text>
|
||||
</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>
|
||||
|
||||
</TouchableOpacity>
|
||||
@@ -493,16 +510,17 @@ const styles = StyleSheet.create({
|
||||
// 食物选项样式
|
||||
foodOptionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F1F5F9',
|
||||
gap: 16,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
foodOptionItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
foodOptionIcon: {
|
||||
width: 24,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const ROUTES = {
|
||||
NUTRITION_RECORDS: '/nutrition/records',
|
||||
FOOD_LIBRARY: '/food-library',
|
||||
VOICE_RECORD: '/voice-record',
|
||||
NUTRITION_LABEL_ANALYSIS: '/food/nutrition-label-analysis',
|
||||
|
||||
// 体重记录相关路由
|
||||
WEIGHT_RECORDS: '/weight-records',
|
||||
|
||||
121
docs/nutrition-label-analysis-implementation.md
Normal file
121
docs/nutrition-label-analysis-implementation.md
Normal 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. 添加成分表数据分享功能
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.18</string>
|
||||
<string>1.0.19</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -47,13 +47,9 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// 非 JSON 响应
|
||||
}
|
||||
const json = await response.json()
|
||||
|
||||
console.log('json', json);
|
||||
|
||||
if (!response.ok) {
|
||||
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;
|
||||
}
|
||||
|
||||
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: ... } 或直接返回对象
|
||||
return (json && (json.data ?? json)) as T;
|
||||
}
|
||||
|
||||
174
services/nutritionLabelAnalysis.ts
Normal file
174
services/nutritionLabelAnalysis.ts
Normal 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: '未知',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user