feat(nutrition): 添加营养成分分析历史记录功能

- 新增历史记录页面,支持查看、筛选和分页加载营养成分分析记录
- 在分析页面添加历史记录入口,使用Liquid Glass效果
- 优化分析结果展示样式,采用卡片式布局和渐变效果
- 移除流式分析相关代码,简化分析流程
- 添加历史记录API接口和类型定义
This commit is contained in:
richarjiang
2025-10-16 16:02:48 +08:00
parent b27099c6d9
commit e4ddd21305
4 changed files with 1038 additions and 117 deletions

View File

@@ -0,0 +1,64 @@
# 常见任务和模式
## HeaderBar 顶部距离处理
**最后更新**: 2025-10-16
### 问题描述
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
### 解决方案
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
### 实现模式
#### 1. 导入必要的 hook
```typescript
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
```
#### 2. 在组件中获取 safeAreaTop
```typescript
const safeAreaTop = useSafeAreaTop()
```
#### 3. 应用到内容容器
```typescript
// 方式1: 直接应用到 View 组件
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
// 方式2: 应用到 ScrollView 的 contentContainerStyle
<ScrollView
contentContainerStyle={{ paddingTop: safeAreaTop }}
>
// 方式3: 应用到 SectionList 的 style
<SectionList
style={{ paddingTop: safeAreaTop }}
>
```
### 重要注意事项
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
### 示例代码
```typescript
// ❌ 错误写法 - 在 StyleSheet 中使用变量
const styles = StyleSheet.create({
filterContainer: {
paddingTop: safeAreaTop, // 这会导致错误
},
});
// ✅ 正确写法 - 使用动态样式
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
```
### 参考页面
- `app/steps/detail.tsx`
- `app/water/detail.tsx`
- `app/profile/goals.tsx`
- `app/workout/history.tsx`
- `app/challenges/[id]/leaderboard.tsx`

View File

@@ -0,0 +1,712 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
getNutritionAnalysisRecords,
type GetNutritionRecordsParams,
type NutritionAnalysisRecord,
type NutritionItem
} from '@/services/nutritionLabelAnalysis';
import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
export default function NutritionAnalysisHistoryScreen() {
const safeAreaTop = useSafeAreaTop();
const router = useRouter();
const [records, setRecords] = useState<NutritionAnalysisRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// 获取历史记录
const fetchRecords = useCallback(async (page: number = 1, isRefresh: boolean = false, currentStatusFilter?: string) => {
try {
// 清除之前的错误
setError(null);
const params: GetNutritionRecordsParams = {
page,
limit: 20,
};
// 使用传入的筛选条件或当前状态
const filterToUse = currentStatusFilter !== undefined ? currentStatusFilter : statusFilter;
if (filterToUse) {
params.status = filterToUse;
}
const response = await getNutritionAnalysisRecords(params);
console.log('response', JSON.stringify(response));
if (response.code === 0) {
const newRecords = response.data.records;
if (isRefresh || page === 1) {
setRecords(newRecords);
} else {
setRecords(prev => [...prev, ...newRecords]);
}
setTotal(response.data.total);
setHasMore(page < response.data.totalPages);
setCurrentPage(page);
} else {
const errorMessage = response.message || '获取历史记录失败';
setError(errorMessage);
Alert.alert('错误', errorMessage);
}
} catch (error) {
console.error('[HISTORY] 获取历史记录失败:', error);
const errorMessage = '获取历史记录失败,请稍后重试';
setError(errorMessage);
Alert.alert('错误', errorMessage);
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
}, [statusFilter]);
// 初始加载 - 只在组件挂载时执行一次
useEffect(() => {
setLoading(true);
fetchRecords(1, true);
}, []); // 移除 fetchRecords 依赖,避免循环
// 筛选条件变化时的处理
useEffect(() => {
// 只有在非初始加载时才执行
if (!loading) {
setLoading(true);
setCurrentPage(1);
fetchRecords(1, true, statusFilter);
}
}, [statusFilter]); // 只依赖 statusFilter
// 下拉刷新
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRecords(1, true);
}, []); // 移除 fetchRecords 依赖
// 加载更多
const handleLoadMore = useCallback(() => {
if (!hasMore || loadingMore || loading || error) return; // 添加错误状态检查
setLoadingMore(true);
fetchRecords(currentPage + 1, false);
}, [hasMore, loadingMore, loading, currentPage, error]); // 移除 fetchRecords 依赖,添加 error 依赖
// 切换展开状态
const toggleExpanded = useCallback((id: number) => {
triggerLightHaptic();
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'success':
return '#4CAF50';
case 'failed':
return '#F44336';
case 'processing':
return '#FF9800';
default:
return '#9E9E9E';
}
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'success':
return '成功';
case 'failed':
return '失败';
case 'processing':
return '处理中';
default:
return '未知';
}
};
// 从营养数据中提取主要营养素的辅助函数
const getMainNutrients = (data: NutritionItem[]) => {
const energy = data.find(item => item.key === 'energy_kcal');
const protein = data.find(item => item.key === 'protein');
const carbs = data.find(item => item.key === 'carbohydrate');
const fat = data.find(item => item.key === 'fat');
return {
energy: energy?.value || '',
protein: protein?.value || '',
carbs: carbs?.value || '',
fat: fat?.value || ''
};
};
// 渲染历史记录项
const renderRecordItem = useCallback(({ item }: { item: NutritionAnalysisRecord }) => {
const isExpanded = expandedItems.has(item.id);
const isSuccess = item.status === 'success';
return (
<View style={styles.recordItem}>
{/* 头部信息 */}
<View style={styles.recordHeader}>
<View style={styles.recordInfo}>
<Text style={styles.recordDate}>
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}>
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
</View>
{isSuccess && (
<Text style={styles.nutritionCount}>
{item.nutritionCount}
</Text>
)}
</View>
{/* 图片预览 */}
{item.imageUrl && (
<View style={styles.imageContainer}>
<Image
source={{ uri: item.imageUrl }}
style={styles.thumbnail}
contentFit="cover"
/>
</View>
)}
{/* 分析结果摘要 */}
{isSuccess && item.analysisResult && item.analysisResult.data && item.analysisResult.data.length > 0 && (
<View style={styles.summaryContainer}>
<View style={styles.nutritionSummary}>
{(() => {
const mainNutrients = getMainNutrients(item.analysisResult.data);
return (
<>
{mainNutrients.energy && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.energy}</Text>
</View>
)}
{mainNutrients.protein && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.protein}</Text>
</View>
)}
{mainNutrients.carbs && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text>
</View>
)}
{mainNutrients.fat && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.fat}</Text>
</View>
)}
</>
);
})()}
</View>
</View>
)}
{/* 失败信息 */}
{!isSuccess && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={20} color="#F44336" />
<Text style={styles.errorMessage}>{item.message}</Text>
</View>
)}
{/* 展开/收起按钮 */}
<TouchableOpacity
style={styles.expandButton}
onPress={() => toggleExpanded(item.id)}
activeOpacity={0.7}
>
<Text style={styles.expandButtonText}>
{isExpanded ? '收起详情' : '展开详情'}
</Text>
<Ionicons
name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'}
size={16}
color={Colors.light.primary}
/>
</TouchableOpacity>
{/* 详细信息 */}
{isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && (
<View style={styles.detailsContainer}>
<Text style={styles.detailsTitle}></Text>
{item.analysisResult.data.map((nutritionItem: NutritionItem) => (
<View key={nutritionItem.key} style={styles.detailItem}>
<View style={styles.nutritionInfo}>
<Text style={styles.detailLabel}>{nutritionItem.name}</Text>
<Text style={styles.detailValue}>{nutritionItem.value}</Text>
</View>
{nutritionItem.analysis && (
<Text style={styles.analysisText}>{nutritionItem.analysis}</Text>
)}
</View>
))}
<View style={styles.metaInfo}>
<Text style={styles.metaText}>AI : {item.aiModel}</Text>
<Text style={styles.metaText}>: {item.aiProvider}</Text>
</View>
</View>
)}
</View>
);
}, [expandedItems, toggleExpanded]);
// 渲染空状态
const renderEmptyState = () => (
<View style={styles.emptyState}>
<Ionicons name="document-text-outline" size={64} color="#CCC" />
<Text style={styles.emptyStateText}></Text>
<Text style={styles.emptyStateSubtext}></Text>
</View>
);
// 渲染错误状态
const renderErrorState = () => (
<View style={styles.errorState}>
<Ionicons name="alert-circle-outline" size={64} color="#F44336" />
<Text style={styles.errorStateText}></Text>
<Text style={styles.errorStateSubtext}>{error || '未知错误'}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
setLoading(true);
fetchRecords(1, true);
}}
>
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
</View>
);
// 渲染底部加载指示器
const renderFooter = () => {
if (!loadingMore) return null;
return (
<View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={Colors.light.primary} />
<Text style={styles.loadingFooterText}>...</Text>
</View>
);
};
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}
/>
{/* 筛选按钮 */}
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
<TouchableOpacity
style={[styles.filterButton, !statusFilter && styles.filterButtonActive]}
onPress={() => {
if (statusFilter !== '') {
setStatusFilter('');
setCurrentPage(1);
// 直接调用数据获取,不依赖 useEffect
setLoading(true);
fetchRecords(1, true, '');
}
}}
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterButton, statusFilter === 'success' && styles.filterButtonActive]}
onPress={() => {
if (statusFilter !== 'success') {
setStatusFilter('success');
setCurrentPage(1);
// 直接调用数据获取,不依赖 useEffect
setLoading(true);
fetchRecords(1, true, 'success');
}
}}
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterButton, statusFilter === 'failed' && styles.filterButtonActive]}
onPress={() => {
if (statusFilter !== 'failed') {
setStatusFilter('failed');
setCurrentPage(1);
// 直接调用数据获取,不依赖 useEffect
setLoading(true);
fetchRecords(1, true, 'failed');
}
}}
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}>
</Text>
</TouchableOpacity>
</View>
{/* 记录列表 */}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<FlatList
data={records}
renderItem={renderRecordItem}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
colors={[Colors.light.primary]}
tintColor={Colors.light.primary}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2} // 提高阈值,减少频繁触发
ListEmptyComponent={error ? renderErrorState : renderEmptyState} // 错误时显示错误状态
ListFooterComponent={renderFooter}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5e5fbff',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
filterContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingBottom: 12,
gap: 8,
},
filterButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.7)',
borderWidth: 1,
borderColor: '#E0E0E0',
},
filterButtonActive: {
backgroundColor: Colors.light.primary,
borderColor: Colors.light.primary,
},
filterButtonText: {
fontSize: 14,
fontWeight: '500',
color: '#666',
},
filterButtonTextActive: {
color: '#FFF',
},
listContainer: {
paddingHorizontal: 16,
paddingBottom: 20,
},
recordItem: {
backgroundColor: Colors.light.background,
borderRadius: 16,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
recordHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
},
recordInfo: {
flex: 1,
},
recordDate: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
marginBottom: 4,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
},
statusText: {
fontSize: 12,
fontWeight: '500',
color: '#FFF',
},
nutritionCount: {
fontSize: 14,
color: Colors.light.textSecondary,
fontWeight: '500',
},
imageContainer: {
marginBottom: 12,
},
thumbnail: {
width: '100%',
height: 120,
borderRadius: 12,
},
summaryContainer: {
marginBottom: 12,
},
nutritionSummary: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
nutritionItem: {
flex: 1,
minWidth: '45%',
backgroundColor: 'rgba(74, 144, 226, 0.1)',
padding: 8,
borderRadius: 8,
},
nutritionLabel: {
fontSize: 12,
color: Colors.light.textSecondary,
marginBottom: 2,
},
nutritionValue: {
fontSize: 14,
fontWeight: '600',
color: Colors.light.primary,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(244, 67, 54, 0.1)',
padding: 12,
borderRadius: 8,
marginBottom: 12,
},
errorMessage: {
fontSize: 14,
color: '#F44336',
marginLeft: 8,
flex: 1,
},
expandButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
},
expandButtonText: {
fontSize: 14,
color: Colors.light.primary,
fontWeight: '500',
marginRight: 4,
},
detailsContainer: {
borderTopWidth: 1,
borderTopColor: '#F0F0F0',
paddingTop: 16,
marginTop: 8,
},
detailsTitle: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
marginBottom: 12,
},
detailItem: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#F8F8F8',
},
detailLabel: {
fontSize: 14,
color: Colors.light.text,
},
nutritionInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
detailValue: {
fontSize: 14,
fontWeight: '600',
color: Colors.light.primary,
},
analysisText: {
fontSize: 12,
color: Colors.light.textSecondary,
lineHeight: 16,
marginTop: 4,
paddingHorizontal: 8,
paddingVertical: 4,
backgroundColor: 'rgba(74, 144, 226, 0.05)',
borderRadius: 6,
},
metaInfo: {
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#F0F0F0',
},
metaText: {
fontSize: 12,
color: Colors.light.textSecondary,
marginBottom: 4,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateText: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
marginTop: 16,
},
emptyStateSubtext: {
fontSize: 14,
color: Colors.light.textSecondary,
marginTop: 8,
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
fontSize: 16,
color: Colors.light.textSecondary,
marginTop: 12,
},
loadingFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
},
loadingFooterText: {
fontSize: 14,
color: Colors.light.textSecondary,
marginLeft: 8,
},
errorState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
errorStateText: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
marginTop: 16,
},
errorStateSubtext: {
fontSize: 14,
color: Colors.light.textSecondary,
marginTop: 8,
textAlign: 'center',
paddingHorizontal: 32,
},
retryButton: {
marginTop: 20,
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: Colors.light.primary,
borderRadius: 24,
},
retryButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -4,12 +4,12 @@ import { useCosUpload } from '@/hooks/useCosUpload';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
analyzeNutritionImage, analyzeNutritionImage,
analyzeNutritionLabelStream,
type NutritionAnalysisResponse type NutritionAnalysisResponse
} from '@/services/nutritionLabelAnalysis'; } from '@/services/nutritionLabelAnalysis';
import { triggerLightHaptic } from '@/utils/haptics'; import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; 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 // 新的分析函数先上传图片到COS然后调用新API
const startNewAnalysis = useCallback(async (uri: string) => { const startNewAnalysis = useCallback(async (uri: string) => {
if (isAnalyzing || isUploading) return; if (isAnalyzing || isUploading) return;
@@ -249,6 +183,31 @@ export default function NutritionLabelAnalysisScreen() {
title="成分表分析" title="成分表分析"
onBack={() => router.back()} onBack={() => router.back()}
transparent={true} 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 <ScrollView
@@ -335,20 +294,49 @@ export default function NutritionLabelAnalysisScreen() {
{/* 新API营养成分详细分析结果 */} {/* 新API营养成分详细分析结果 */}
{newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && ( {newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && (
<View style={styles.nutritionDetailsCard}> <View style={styles.analysisSection}>
<View style={styles.nutritionDetailsHeader}> <View style={styles.analysisSectionHeader}>
<Text style={styles.nutritionDetailsTitle}></Text> <View style={styles.analysisSectionHeaderIcon}>
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
</View> </View>
<Text style={styles.analysisSectionTitle}></Text>
</View>
<View style={styles.analysisCardsWrapper}>
{newAnalysisResult.data.map((item, index) => ( {newAnalysisResult.data.map((item, index) => (
<View key={item.key || index} style={styles.nutritionDetailItem}> <View
<View style={styles.nutritionDetailHeader}> key={item.key || index}
<Text style={styles.nutritionDetailName}>{item.name}</Text> style={[
<Text style={styles.nutritionDetailValue}>{item.value}</Text> 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>
<Text style={styles.nutritionDetailAnalysis}>{item.analysis}</Text>
</View> </View>
))} ))}
</View> </View>
</View>
)} )}
{/* 上传状态 */} {/* 上传状态 */}
@@ -660,58 +648,121 @@ const styles = StyleSheet.create({
marginLeft: 6, marginLeft: 6,
}, },
// 营养成分详细分析卡片样式 // 营养成分详细分析卡片样式
nutritionDetailsCard: { analysisSection: {
backgroundColor: Colors.light.background, marginHorizontal: 16,
margin: 16, marginTop: 28,
borderRadius: 16, marginBottom: 20,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
}, },
shadowOpacity: 0.1, analysisSectionHeader: {
shadowRadius: 4, flexDirection: 'row',
elevation: 3, alignItems: 'center',
marginBottom: 14,
}, },
nutritionDetailsHeader: { analysisSectionHeaderIcon: {
marginBottom: 16, width: 32,
height: 32,
borderRadius: 12,
backgroundColor: 'rgba(107, 110, 214, 0.12)',
alignItems: 'center',
justifyContent: 'center',
}, },
nutritionDetailsTitle: { analysisSectionTitle: {
marginLeft: 10,
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
color: Colors.light.text, color: Colors.light.text,
}, },
nutritionDetailItem: { analysisCardsWrapper: {
marginBottom: 20, backgroundColor: '#FFFFFF',
paddingBottom: 16, borderRadius: 28,
borderBottomWidth: 1, padding: 18,
borderBottomColor: '#F0F0F0', shadowColor: 'rgba(107, 110, 214, 0.25)',
shadowOffset: {
width: 0,
height: 10,
}, },
nutritionDetailHeader: { 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', flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: 8, marginBottom: 8,
}, },
nutritionDetailName: { analysisItemName: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
flex: 1, flex: 1,
},
nutritionDetailValue: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: Colors.light.primary, color: '#272753',
backgroundColor: 'rgba(74, 144, 226, 0.1)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
}, },
nutritionDetailAnalysis: { analysisItemValue: {
fontSize: 14, fontSize: 16,
lineHeight: 20, fontWeight: '700',
color: Colors.light.textSecondary, 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,
}, },
}); });

View File

@@ -135,3 +135,97 @@ export async function analyzeNutritionImage(request: NutritionAnalysisRequest):
}; };
} }
} }
// 营养成分分析记录的接口定义
export interface NutritionAnalysisRecord {
id: number;
userId: string;
imageUrl: string;
analysisResult: {
data: NutritionItem[];
success: boolean;
message?: string;
};
status: 'success' | 'failed' | 'processing';
message: string;
aiProvider: string;
aiModel: string;
nutritionCount: number;
createdAt: string;
updatedAt: string;
}
// 获取历史记录的请求参数
export interface GetNutritionRecordsParams {
startDate?: string;
endDate?: string;
status?: string;
page?: number;
limit?: number;
}
// 获取历史记录的响应格式
export interface GetNutritionRecordsResponse {
code: number;
message: string;
data: {
records: NutritionAnalysisRecord[];
total: number;
page: number;
limit: number;
totalPages: number;
};
}
/**
* 获取营养成分分析记录列表
*/
export async function getNutritionAnalysisRecords(params?: GetNutritionRecordsParams): Promise<GetNutritionRecordsResponse> {
try {
const searchParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, String(value));
}
});
}
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
// 使用 api.get 方法,但需要特殊处理响应格式
const response = await api.get<any>(`/diet-records/nutrition-analysis-records${queryString}`);
// 检查响应是否已经是标准格式
if (response && typeof response === 'object' && 'code' in response) {
return response as GetNutritionRecordsResponse;
}
// 如果不是标准格式,包装成标准格式
return {
code: 0,
message: '获取成功',
data: {
records: response.records || [],
total: response.total || 0,
page: response.page || 1,
limit: response.limit || 20,
totalPages: response.totalPages || Math.ceil((response.total || 0) / (response.limit || 20))
}
};
} catch (error) {
console.error('[NUTRITION_RECORDS] 获取历史记录失败:', error);
// 返回错误格式的响应
return {
code: 1,
message: error instanceof Error ? error.message : '获取营养成分分析记录失败,请稍后重试',
data: {
records: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0
}
};
}
}