diff --git a/.kilocode/rules/kilo-rule.md b/.kilocode/rules/kilo-rule.md index 4033e03..a3f6215 100644 --- a/.kilocode/rules/kilo-rule.md +++ b/.kilocode/rules/kilo-rule.md @@ -6,5 +6,7 @@ - 遇到比较复杂的页面,尽量使用可以复用的组件 - 不要尝试使用 `npm run ios` 命令 - 优先使用 Liquid Glass 风格组件 +- 注重代码的可读性,尽量增加注释 +- 不要随意新增 md 文档 diff --git a/app.json b/app.json index c8a497f..c44bd1b 100644 --- a/app.json +++ b/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", diff --git a/app/food/nutrition-label-analysis.tsx b/app/food/nutrition-label-analysis.tsx new file mode 100644 index 0000000..319aee1 --- /dev/null +++ b/app/food/nutrition-label-analysis.tsx @@ -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(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [showImagePreview, setShowImagePreview] = useState(false); + const [analysisText, setAnalysisText] = useState(''); + const [analysisResult, setAnalysisResult] = useState(null); + const [newAnalysisResult, setNewAnalysisResult] = useState(null); + const [isUploading, setIsUploading] = useState(false); + + // 流式请求相关引用 + const streamAbortRef = useRef<{ abort: () => void } | null>(null); + const scrollViewRef = useRef(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 ( + + {/* 背景渐变 */} + + + router.back()} + transparent={true} + /> + + + {/* 图片区域 */} + + {imageUri ? ( + + setShowImagePreview(true)} + activeOpacity={0.9} + > + + {/* 预览提示图标 */} + + + + + + {/* 开始分析按钮 */} + {!isAnalyzing && !isUploading && !analysisText && ( + startNewAnalysis(imageUri)} + activeOpacity={0.8} + > + + 开始分析 + + )} + + {/* 删除图片按钮 */} + { + setImageUri(null); + setAnalysisText(''); + setAnalysisResult(null); + setNewAnalysisResult(null); + triggerLightHaptic(); + }} + activeOpacity={0.8} + > + + + + ) : ( + + + + 拍摄或选择成分表照片 + + {/* 操作按钮区域 */} + + + + 拍摄 + + + + 相册 + + + + )} + + + {/* 流式分析文本区域 */} + {analysisText && ( + + + 分析结果 + {isAnalyzing && ( + + + 停止 + + )} + + {analysisText} + {isAnalyzing && ( + + + 正在分析中... + + )} + + )} + + {/* 新API营养成分详细分析结果 */} + {newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && ( + + + 营养成分详细分析 + + {newAnalysisResult.data.map((item, index) => ( + + + {item.name} + {item.value} + + {item.analysis} + + ))} + + )} + + {/* 上传状态 */} + {isUploading && ( + + + + 正在上传图片... {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''} + + + )} + + {/* 加载状态 */} + {isAnalyzing && !analysisText && !isUploading && ( + + + 正在分析成分表... + + )} + + + {/* 图片预览 */} + setShowImagePreview(false)} + swipeToCloseEnabled={true} + doubleTapToZoomEnabled={true} + HeaderComponent={() => ( + + + {dayjs().format('YYYY年M月D日 HH:mm')} + + + )} + FooterComponent={() => ( + + setShowImagePreview(false)} + > + 关闭 + + + )} + /> + + ); +} + +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, + }, +}); \ No newline at end of file diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index c08e64c..932ec0d 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -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({ 一句话记录 + + { + triggerLightHaptic(); + pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`); + }} + activeOpacity={0.7} + > + + + + 成分表分析 + @@ -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, diff --git a/constants/Routes.ts b/constants/Routes.ts index e784a75..37b686a 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -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', @@ -62,7 +63,7 @@ export const ROUTES = { // 目标管理路由 (已移至tab中) // GOAL_MANAGEMENT: '/goal-management', - + // 开发者相关路由 DEVELOPER: '/developer', DEVELOPER_LOGS: '/developer/logs', diff --git a/docs/nutrition-label-analysis-implementation.md b/docs/nutrition-label-analysis-implementation.md new file mode 100644 index 0000000..9b8f273 --- /dev/null +++ b/docs/nutrition-label-analysis-implementation.md @@ -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 + { + triggerLightHaptic(); + pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`); + }} + activeOpacity={0.7} +> + + + + 成分表分析 + +``` + +### 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 { + // 实现图片上传和识别逻辑 +} + +// 保存成分表分析结果到饮食记录 +export async function saveNutritionLabelToDietRecord( + analysisResult: NutritionLabelAnalysisResult, + mealType: MealType +): Promise { + // 实现保存逻辑 +} +``` + +## UI设计考虑 +1. **Liquid Glass风格**:使用毛玻璃效果和渐变背景 +2. **动画效果**:添加识别过程中的加载动画和结果展示动画 +3. **交互反馈**:使用触觉反馈和视觉反馈提升用户体验 +4. **错误处理**:提供友好的错误提示和重试机制 + +## 测试计划 +1. 测试拍照功能 +2. 测试图片识别功能 +3. 测试数据保存功能 +4. 测试错误处理 +5. 测试与现有功能的集成 + +## 后续优化 +1. 支持多语言成分表识别 +2. 添加成分表历史记录 +3. 实现成分表数据对比功能 +4. 添加成分表数据分享功能 \ No newline at end of file diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index c1c0fe3..83f3444 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -25,7 +25,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.18 + 1.0.19 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/api.ts b/services/api.ts index 6bba1c9..61752d4 100644 --- a/services/api.ts +++ b/services/api.ts @@ -47,13 +47,9 @@ async function doFetch(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(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; } diff --git a/services/nutritionLabelAnalysis.ts b/services/nutritionLabelAnalysis.ts new file mode 100644 index 0000000..3df095d --- /dev/null +++ b/services/nutritionLabelAnalysis.ts @@ -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 { + return api.post('/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 { + 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 { + return api.post('/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: '未知', + }; +} \ No newline at end of file