feat(nutrition): 添加营养成分分析历史记录功能
- 新增历史记录页面,支持查看、筛选和分页加载营养成分分析记录 - 在分析页面添加历史记录入口,使用Liquid Glass效果 - 优化分析结果展示样式,采用卡片式布局和渐变效果 - 移除流式分析相关代码,简化分析流程 - 添加历史记录API接口和类型定义
This commit is contained in:
64
.kilocode/rules/memory-bank/tasks.md
Normal file
64
.kilocode/rules/memory-bank/tasks.md
Normal 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`
|
||||||
712
app/food/nutrition-analysis-history.tsx
Normal file
712
app/food/nutrition-analysis-history.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user