From e4ddd2130500ed396e919ed92364560d45478a2d Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 16 Oct 2025 16:02:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(nutrition):=20=E6=B7=BB=E5=8A=A0=E8=90=A5?= =?UTF-8?q?=E5=85=BB=E6=88=90=E5=88=86=E5=88=86=E6=9E=90=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增历史记录页面,支持查看、筛选和分页加载营养成分分析记录 - 在分析页面添加历史记录入口,使用Liquid Glass效果 - 优化分析结果展示样式,采用卡片式布局和渐变效果 - 移除流式分析相关代码,简化分析流程 - 添加历史记录API接口和类型定义 --- .kilocode/rules/memory-bank/tasks.md | 64 +++ app/food/nutrition-analysis-history.tsx | 712 ++++++++++++++++++++++++ app/food/nutrition-label-analysis.tsx | 285 ++++++---- services/nutritionLabelAnalysis.ts | 94 ++++ 4 files changed, 1038 insertions(+), 117 deletions(-) create mode 100644 .kilocode/rules/memory-bank/tasks.md create mode 100644 app/food/nutrition-analysis-history.tsx diff --git a/.kilocode/rules/memory-bank/tasks.md b/.kilocode/rules/memory-bank/tasks.md new file mode 100644 index 0000000..3fcdeb7 --- /dev/null +++ b/.kilocode/rules/memory-bank/tasks.md @@ -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 组件 + + +// 方式2: 应用到 ScrollView 的 contentContainerStyle + + +// 方式3: 应用到 SectionList 的 style + +``` + +### 重要注意事项 +1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量 +2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop` +3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值 + +### 示例代码 +```typescript +// ❌ 错误写法 - 在 StyleSheet 中使用变量 +const styles = StyleSheet.create({ + filterContainer: { + paddingTop: safeAreaTop, // 这会导致错误 + }, +}); + +// ✅ 正确写法 - 使用动态样式 + +``` + +### 参考页面 +- `app/steps/detail.tsx` +- `app/water/detail.tsx` +- `app/profile/goals.tsx` +- `app/workout/history.tsx` +- `app/challenges/[id]/leaderboard.tsx` \ No newline at end of file diff --git a/app/food/nutrition-analysis-history.tsx b/app/food/nutrition-analysis-history.tsx new file mode 100644 index 0000000..40df38a --- /dev/null +++ b/app/food/nutrition-analysis-history.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + const [statusFilter, setStatusFilter] = useState(''); + const [error, setError] = useState(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 ( + + {/* 头部信息 */} + + + + {dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')} + + + {getStatusText(item.status)} + + + + {isSuccess && ( + + 识别 {item.nutritionCount} 项营养素 + + )} + + + {/* 图片预览 */} + {item.imageUrl && ( + + + + )} + + {/* 分析结果摘要 */} + {isSuccess && item.analysisResult && item.analysisResult.data && item.analysisResult.data.length > 0 && ( + + + {(() => { + const mainNutrients = getMainNutrients(item.analysisResult.data); + return ( + <> + {mainNutrients.energy && ( + + 热量 + {mainNutrients.energy} + + )} + {mainNutrients.protein && ( + + 蛋白质 + {mainNutrients.protein} + + )} + {mainNutrients.carbs && ( + + 碳水 + {mainNutrients.carbs} + + )} + {mainNutrients.fat && ( + + 脂肪 + {mainNutrients.fat} + + )} + + ); + })()} + + + )} + + {/* 失败信息 */} + {!isSuccess && ( + + + {item.message} + + )} + + {/* 展开/收起按钮 */} + toggleExpanded(item.id)} + activeOpacity={0.7} + > + + {isExpanded ? '收起详情' : '展开详情'} + + + + + {/* 详细信息 */} + {isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && ( + + 详细营养成分 + {item.analysisResult.data.map((nutritionItem: NutritionItem) => ( + + + {nutritionItem.name} + {nutritionItem.value} + + {nutritionItem.analysis && ( + {nutritionItem.analysis} + )} + + ))} + + + AI 模型: {item.aiModel} + 服务提供商: {item.aiProvider} + + + )} + + ); + }, [expandedItems, toggleExpanded]); + + // 渲染空状态 + const renderEmptyState = () => ( + + + 暂无历史记录 + 开始识别营养成分表吧 + + ); + + // 渲染错误状态 + const renderErrorState = () => ( + + + 加载失败 + {error || '未知错误'} + { + setLoading(true); + fetchRecords(1, true); + }} + > + 重试 + + + ); + + // 渲染底部加载指示器 + const renderFooter = () => { + if (!loadingMore) return null; + + return ( + + + 加载更多... + + ); + }; + + return ( + + {/* 背景渐变 */} + + + router.back()} + transparent={true} + /> + + {/* 筛选按钮 */} + + { + if (statusFilter !== '') { + setStatusFilter(''); + setCurrentPage(1); + // 直接调用数据获取,不依赖 useEffect + setLoading(true); + fetchRecords(1, true, ''); + } + }} + activeOpacity={0.7} + > + + 全部 + + + { + if (statusFilter !== 'success') { + setStatusFilter('success'); + setCurrentPage(1); + // 直接调用数据获取,不依赖 useEffect + setLoading(true); + fetchRecords(1, true, 'success'); + } + }} + activeOpacity={0.7} + > + + 成功 + + + { + if (statusFilter !== 'failed') { + setStatusFilter('failed'); + setCurrentPage(1); + // 直接调用数据获取,不依赖 useEffect + setLoading(true); + fetchRecords(1, true, 'failed'); + } + }} + activeOpacity={0.7} + > + + 失败 + + + + + {/* 记录列表 */} + {loading ? ( + + + 加载历史记录... + + ) : ( + item.id.toString()} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.2} // 提高阈值,减少频繁触发 + ListEmptyComponent={error ? renderErrorState : renderEmptyState} // 错误时显示错误状态 + ListFooterComponent={renderFooter} + /> + )} + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/app/food/nutrition-label-analysis.tsx b/app/food/nutrition-label-analysis.tsx index b16dae2..2574629 100644 --- a/app/food/nutrition-label-analysis.tsx +++ b/app/food/nutrition-label-analysis.tsx @@ -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() ? ( + router.push('/food/nutrition-analysis-history')} + activeOpacity={0.7} + > + + + + + ) : ( + router.push('/food/nutrition-analysis-history')} + style={[styles.historyButton, styles.fallbackBackground]} + activeOpacity={0.7} + > + + + ) + } /> - - 营养成分详细分析 - - {newAnalysisResult.data.map((item, index) => ( - - - {item.name} - {item.value} - - {item.analysis} + + + + - ))} + 营养成分详细分析 + + + {newAnalysisResult.data.map((item, index) => ( + + + + + + + {item.name} + {item.value} + + + + {item.analysis} + + + + ))} + )} @@ -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', }, -}); \ No newline at end of file + 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, + }, +}); diff --git a/services/nutritionLabelAnalysis.ts b/services/nutritionLabelAnalysis.ts index 41022c4..e0852c6 100644 --- a/services/nutritionLabelAnalysis.ts +++ b/services/nutritionLabelAnalysis.ts @@ -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 { + 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(`/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 + } + }; + } +}