feat(nutrition): 添加营养成分分析历史记录功能
- 新增历史记录页面,支持查看、筛选和分页加载营养成分分析记录 - 在分析页面添加历史记录入口,使用Liquid Glass效果 - 优化分析结果展示样式,采用卡片式布局和渐变效果 - 移除流式分析相关代码,简化分析流程 - 添加历史记录API接口和类型定义
This commit is contained in:
@@ -4,12 +4,12 @@ import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
analyzeNutritionImage,
|
||||
analyzeNutritionLabelStream,
|
||||
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';
|
||||
@@ -118,72 +118,6 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 取消当前分析
|
||||
const cancelAnalysis = useCallback(() => {
|
||||
try {
|
||||
console.log('[NUTRITION_ANALYSIS] User cancelled analysis');
|
||||
|
||||
// 中断网络请求
|
||||
if (streamAbortRef.current) {
|
||||
streamAbortRef.current.abort();
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
|
||||
// 清理状态
|
||||
setIsAnalyzing(false);
|
||||
|
||||
// 触觉反馈
|
||||
triggerLightHaptic();
|
||||
} catch (error) {
|
||||
console.warn('[NUTRITION_ANALYSIS] Error cancelling analysis:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 开始流式分析图片
|
||||
const startAnalysis = useCallback(async (uri: string) => {
|
||||
if (isAnalyzing) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// 延迟滚动到分析结果区域,给UI一些时间更新
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ y: 350, animated: true });
|
||||
}, 300);
|
||||
|
||||
let isFirstChunk = true;
|
||||
|
||||
try {
|
||||
await analyzeNutritionLabelStream(
|
||||
{ imageUri: uri },
|
||||
{
|
||||
onChunk: (chunk: string) => {
|
||||
// 流式分析暂时保留,但不再显示文本
|
||||
console.log('[NUTRITION_ANALYSIS] Stream chunk:', chunk);
|
||||
},
|
||||
onEnd: () => {
|
||||
setIsAnalyzing(false);
|
||||
streamAbortRef.current = null;
|
||||
console.log('[NUTRITION_ANALYSIS] Analysis completed');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('[NUTRITION_ANALYSIS] Analysis failed:', error);
|
||||
setIsAnalyzing(false);
|
||||
streamAbortRef.current = null;
|
||||
|
||||
// 如果是用户主动取消,不显示错误提示
|
||||
if (error?.name !== 'AbortError' && !error?.message?.includes('abort')) {
|
||||
Alert.alert('分析失败', '无法识别成分表,请尝试拍摄更清晰的照片');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[NUTRITION_ANALYSIS] Analysis error:', error);
|
||||
setIsAnalyzing(false);
|
||||
Alert.alert('分析失败', '无法识别成分表,请尝试拍摄更清晰的照片');
|
||||
}
|
||||
}, [isAnalyzing]);
|
||||
|
||||
// 新的分析函数:先上传图片到COS,然后调用新API
|
||||
const startNewAnalysis = useCallback(async (uri: string) => {
|
||||
if (isAnalyzing || isUploading) return;
|
||||
@@ -249,6 +183,31 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
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
|
||||
@@ -335,19 +294,48 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
|
||||
{/* 新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 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>
|
||||
)}
|
||||
|
||||
@@ -660,58 +648,121 @@ const styles = StyleSheet.create({
|
||||
marginLeft: 6,
|
||||
},
|
||||
// 营养成分详细分析卡片样式
|
||||
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,
|
||||
analysisSection: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 28,
|
||||
marginBottom: 20,
|
||||
},
|
||||
nutritionDetailsHeader: {
|
||||
marginBottom: 16,
|
||||
analysisSectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
nutritionDetailsTitle: {
|
||||
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,
|
||||
},
|
||||
nutritionDetailItem: {
|
||||
marginBottom: 20,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
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,
|
||||
},
|
||||
nutritionDetailHeader: {
|
||||
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',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
nutritionDetailName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
analysisItemName: {
|
||||
flex: 1,
|
||||
},
|
||||
nutritionDetailValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#272753',
|
||||
},
|
||||
analysisItemValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
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,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user