Files
digital-pilates/app/food/nutrition-label-analysis.tsx
richarjiang e4ddd21305 feat(nutrition): 添加营养成分分析历史记录功能
- 新增历史记录页面,支持查看、筛选和分页加载营养成分分析记录
- 在分析页面添加历史记录入口,使用Liquid Glass效果
- 优化分析结果展示样式,采用卡片式布局和渐变效果
- 移除流式分析相关代码,简化分析流程
- 添加历史记录API接口和类型定义
2025-10-16 16:02:48 +08:00

769 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
analyzeNutritionImage,
type NutritionAnalysisResponse
} from '@/services/nutritionLabelAnalysis';
import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
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 [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: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setNewAnalysisResult(null); // 清除之前的分析结果
}
};
// 从相册选择
const pickImage = async () => {
triggerLightHaptic();
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setNewAnalysisResult(null); // 清除之前的分析结果
}
};
// 新的分析函数先上传图片到COS然后调用新API
const startNewAnalysis = useCallback(async (uri: string) => {
if (isAnalyzing || isUploading) return;
setIsUploading(true);
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) {
// 直接使用服务端返回的数据,不做任何转换
setNewAnalysisResult(analysisResponse);
} else {
throw new Error(analysisResponse.message || '分析失败');
}
} catch (error: any) {
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
setIsUploading(false);
setIsAnalyzing(false);
// 显示错误提示
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}
right={
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => router.push('/food/nutrition-analysis-history')}
activeOpacity={0.7}
>
<GlassView
style={styles.historyButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.2)"
isInteractive={true}
>
<Ionicons name="time-outline" size={24} color="#333" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => router.push('/food/nutrition-analysis-history')}
style={[styles.historyButton, styles.fallbackBackground]}
activeOpacity={0.7}
>
<Ionicons name="time-outline" size={24} color="#333" />
</TouchableOpacity>
)
}
/>
<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 && !newAnalysisResult && (
<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);
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>
{/* 新API营养成分详细分析结果 */}
{newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && (
<View style={styles.analysisSection}>
<View style={styles.analysisSectionHeader}>
<View style={styles.analysisSectionHeaderIcon}>
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
</View>
<Text style={styles.analysisSectionTitle}></Text>
</View>
<View style={styles.analysisCardsWrapper}>
{newAnalysisResult.data.map((item, index) => (
<View
key={item.key || index}
style={[
styles.analysisCardItem,
index === newAnalysisResult.data.length - 1 && styles.analysisCardItemLast
]}
>
<LinearGradient
colors={['#7F77FF', '#9B7CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.analysisItemIconGradient}
>
<Ionicons name="nutrition-outline" size={24} color="#FFFFFF" />
</LinearGradient>
<View style={styles.analysisItemContent}>
<View style={styles.analysisItemHeader}>
<Text style={styles.analysisItemName}>{item.name}</Text>
<Text style={styles.analysisItemValue}>{item.value}</Text>
</View>
<View style={styles.analysisItemDescriptionRow}>
<Ionicons
name="information-circle-outline"
size={16}
color="rgba(107, 110, 214, 0.8)"
style={styles.analysisItemDescriptionIcon}
/>
<Text style={styles.analysisItemDescription}>{item.analysis}</Text>
</View>
</View>
</View>
))}
</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 && !newAnalysisResult && !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,
},
// 营养成分详细分析卡片样式
analysisSection: {
marginHorizontal: 16,
marginTop: 28,
marginBottom: 20,
},
analysisSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
analysisSectionHeaderIcon: {
width: 32,
height: 32,
borderRadius: 12,
backgroundColor: 'rgba(107, 110, 214, 0.12)',
alignItems: 'center',
justifyContent: 'center',
},
analysisSectionTitle: {
marginLeft: 10,
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
},
analysisCardsWrapper: {
backgroundColor: '#FFFFFF',
borderRadius: 28,
padding: 18,
shadowColor: 'rgba(107, 110, 214, 0.25)',
shadowOffset: {
width: 0,
height: 10,
},
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 6,
},
analysisCardItem: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
backgroundColor: 'rgba(127, 119, 255, 0.1)',
borderRadius: 22,
shadowColor: 'rgba(127, 119, 255, 0.28)',
shadowOffset: {
width: 0,
height: 6,
},
shadowOpacity: 0.16,
shadowRadius: 12,
elevation: 4,
marginBottom: 12,
},
analysisCardItemLast: {
marginBottom: 0,
},
analysisItemIconGradient: {
width: 52,
height: 52,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
},
analysisItemContent: {
flex: 1,
},
analysisItemHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
analysisItemName: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#272753',
},
analysisItemValue: {
fontSize: 16,
fontWeight: '700',
color: Colors.light.primary,
},
analysisItemDescriptionRow: {
flexDirection: 'row',
alignItems: 'flex-start',
},
analysisItemDescriptionIcon: {
marginRight: 6,
marginTop: 2,
},
analysisItemDescription: {
flex: 1,
fontSize: 13,
lineHeight: 18,
color: 'rgba(39, 39, 83, 0.72)',
},
// 历史记录按钮样式
historyButton: {
width: 38,
height: 38,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 19,
overflow: 'hidden',
},
fallbackBackground: {
backgroundColor: 'rgba(255, 255, 255, 0.7)',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
});