feat(nutrition): 添加营养成分表拍照分析功能
新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息: - 创建成分表分析页面,支持拍照/选择图片和结果展示 - 集成新的营养成分分析API,支持图片上传和流式分析 - 在营养雷达卡片中添加成分表分析入口 - 更新应用版本至1.0.19
This commit is contained in:
@@ -6,5 +6,7 @@
|
|||||||
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
||||||
- 不要尝试使用 `npm run ios` 命令
|
- 不要尝试使用 `npm run ios` 命令
|
||||||
- 优先使用 Liquid Glass 风格组件
|
- 优先使用 Liquid Glass 风格组件
|
||||||
|
- 注重代码的可读性,尽量增加注释
|
||||||
|
- 不要随意新增 md 文档
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
app.json
2
app.json
@@ -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",
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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