Files
digital-pilates/app/food/nutrition-label-analysis.tsx
richarjiang b75a8991ac feat(auth): 添加登录验证到食物记录相关功能
- 在食物拍照、语音记录和营养成分分析功能中添加登录验证
- 使用 ensureLoggedIn 方法确保用户已登录后再调用服务端接口
- 使用 pushIfAuthedElseLogin 方法处理需要登录的页面导航
- 添加新的营养图标资源
- 在路由常量中添加 FOOD_CAMERA 路由定义
- 更新 Memory Bank 任务文档,记录登录验证和路由常量管理的实现模式
2025-10-16 17:45:52 +08:00

782 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 { useAuthGuard } from '@/hooks/useAuthGuard';
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 { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
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={() => pushIfAuthedElseLogin('/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={() => pushIfAuthedElseLogin('/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={async () => {
// 先验证登录状态
const isLoggedIn = await ensureLoggedIn();
if (isLoggedIn) {
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: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
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,
},
});