feat: add food camera and recognition features
- Implemented FoodCameraScreen for capturing food images with meal type selection. - Created FoodRecognitionScreen for processing and recognizing food images. - Added Redux slice for managing food recognition state and results. - Integrated image upload functionality to cloud storage. - Enhanced UI components for better user experience during food recognition. - Updated FloatingFoodOverlay to navigate to the new camera screen. - Added food recognition service for API interaction. - Improved styling and layout for various components.
This commit is contained in:
750
app/food/food-recognition.tsx
Normal file
750
app/food/food-recognition.tsx
Normal file
@@ -0,0 +1,750 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { recognizeFood } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Animated,
|
||||
Easing,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function FoodRecognitionScreen() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
imageUri?: string;
|
||||
mealType?: string;
|
||||
}>();
|
||||
|
||||
const { imageUri, mealType } = params;
|
||||
const { upload, uploading } = useCosUpload();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showRecognitionProcess, setShowRecognitionProcess] = useState(false);
|
||||
const [recognitionLogs, setRecognitionLogs] = useState<string[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// 动画引用
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(50)).current;
|
||||
const progressAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// 启动动画效果
|
||||
useEffect(() => {
|
||||
if (showRecognitionProcess) {
|
||||
// 进入动画
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
|
||||
// 启动进度条动画
|
||||
if (currentStep === 'uploading' || currentStep === 'recognizing') {
|
||||
Animated.timing(progressAnim, {
|
||||
toValue: currentStep === 'uploading' ? 0.5 : 1,
|
||||
duration: 2000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}
|
||||
|
||||
// 脉冲动画
|
||||
const pulseAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.1,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
useNativeDriver: true,
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
if (currentStep === 'uploading' || currentStep === 'recognizing') {
|
||||
pulseAnimation.start();
|
||||
} else {
|
||||
pulseAnimation.stop();
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
} else {
|
||||
fadeAnim.setValue(0);
|
||||
slideAnim.setValue(50);
|
||||
progressAnim.setValue(0);
|
||||
}
|
||||
}, [showRecognitionProcess, currentStep]);
|
||||
|
||||
const addLog = (message: string) => {
|
||||
setRecognitionLogs(prev => [...prev, message]);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!imageUri) return;
|
||||
|
||||
// 按钮动画效果
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.95,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
|
||||
try {
|
||||
setShowRecognitionProcess(true);
|
||||
setRecognitionLogs([]);
|
||||
setCurrentStep('uploading');
|
||||
setIsUploading(true);
|
||||
dispatch(setLoading(true));
|
||||
|
||||
addLog('📤 正在上传图片到云端...');
|
||||
|
||||
// 上传图片到 COS
|
||||
const { url } = await upload(
|
||||
{ uri: imageUri, name: 'food-image.jpg', type: 'image/jpeg' },
|
||||
{ prefix: 'food-images/' }
|
||||
);
|
||||
|
||||
addLog('✅ 图片上传完成');
|
||||
addLog('🤖 AI大模型分析中...');
|
||||
setCurrentStep('recognizing');
|
||||
|
||||
// 调用食物识别 API
|
||||
const recognitionResult = await recognizeFood({
|
||||
imageUrls: [url]
|
||||
});
|
||||
|
||||
console.log('食物识别结果:', recognitionResult);
|
||||
|
||||
if (!recognitionResult.isFoodDetected) {
|
||||
addLog('❌ 识别失败:未检测到食物');
|
||||
addLog(`💭 ${recognitionResult.nonFoodMessage || recognitionResult.analysisText}`);
|
||||
setCurrentStep('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
addLog('✅ AI分析完成');
|
||||
addLog(`🎯 识别置信度: ${recognitionResult.confidence}%`);
|
||||
addLog(`🍽️ 识别到 ${recognitionResult.items.length} 种食物`);
|
||||
|
||||
setCurrentStep('completed');
|
||||
|
||||
// 生成唯一的识别ID
|
||||
const recognitionId = `recognition_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// 保存识别结果到 Redux
|
||||
await dispatch(saveRecognitionResult({
|
||||
id: recognitionId,
|
||||
result: recognitionResult
|
||||
}));
|
||||
|
||||
// 延迟跳转,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
router.replace(`/food/analysis-result?imageUri=${encodeURIComponent(url)}&mealType=${mealType}&recognitionId=${recognitionId}`);
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('食物识别失败', error);
|
||||
addLog('❌ 识别过程出错');
|
||||
addLog(`💥 ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
setCurrentStep('failed');
|
||||
dispatch(setError('食物识别失败,请重试'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setShowRecognitionProcess(false);
|
||||
setCurrentStep('idle');
|
||||
setRecognitionLogs([]);
|
||||
dispatch(setError(null));
|
||||
|
||||
router.back()
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (showRecognitionProcess && currentStep !== 'failed') {
|
||||
Alert.alert(
|
||||
'正在识别中',
|
||||
'识别过程尚未完成,确定要返回吗?',
|
||||
[
|
||||
{ text: '继续识别', style: 'cancel' },
|
||||
{ text: '返回', style: 'destructive', onPress: () => router.back() }
|
||||
]
|
||||
);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
if (!imageUri) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<HeaderBar
|
||||
title="食物识别"
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>未找到图片</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title={showRecognitionProcess ? "食物识别" : "确认食物"}
|
||||
onBack={handleGoBack}
|
||||
/>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<ScrollView style={styles.contentContainer} showsVerticalScrollIndicator={false}>
|
||||
{!showRecognitionProcess ? (
|
||||
// 确认界面
|
||||
<>
|
||||
{/* 照片卡片 */}
|
||||
<View style={styles.photoCard}>
|
||||
<View style={styles.photoFrame}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.photoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* 餐次标签叠加 */}
|
||||
{mealType && (
|
||||
<View style={styles.mealTypeBadge}>
|
||||
<Text style={styles.mealTypeBadgeText}>
|
||||
{getMealTypeLabel(mealType)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* AI 识别说明卡片 */}
|
||||
<View style={styles.infoCard}>
|
||||
<View style={styles.infoHeader}>
|
||||
<View style={styles.aiIconContainer}>
|
||||
<Ionicons name="sparkles" size={20} color={Colors.light.primary} />
|
||||
</View>
|
||||
<Text style={styles.infoTitle}>智能食物识别</Text>
|
||||
</View>
|
||||
<Text style={styles.infoDescription}>
|
||||
AI 将分析您的照片,识别食物种类、估算营养成分,为您生成详细的营养分析报告
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 底部按钮区域 */}
|
||||
<View style={styles.bottomContainer}>
|
||||
<Animated.View style={[styles.confirmButtonContainer, { transform: [{ scale: scaleAnim }] }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.confirmButton}
|
||||
onPress={handleConfirm}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.confirmButtonContent}>
|
||||
<Ionicons name="scan" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.confirmButtonText}>开始智能识别</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
// 识别过程界面
|
||||
<Animated.View style={[styles.recognitionContainer, {
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateY: slideAnim }]
|
||||
}]}>
|
||||
{/* 照片缩略图卡片 */}
|
||||
<View style={styles.thumbnailCard}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.thumbnailImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.thumbnailInfo}>
|
||||
{mealType && (
|
||||
<View style={styles.thumbnailMealType}>
|
||||
<Text style={styles.thumbnailMealTypeText}>
|
||||
{getMealTypeLabel(mealType)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.thumbnailTitle}>AI 识别中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 进度指示卡片 */}
|
||||
<View style={styles.progressCard}>
|
||||
<View style={styles.progressHeader}>
|
||||
<Animated.View style={[styles.statusIconAnimated, { transform: [{ scale: pulseAnim }] }]}>
|
||||
<View style={[styles.statusIcon, {
|
||||
backgroundColor: currentStep === 'uploading' || currentStep === 'recognizing' ? Colors.light.primary :
|
||||
currentStep === 'completed' ? Colors.light.success :
|
||||
currentStep === 'failed' ? Colors.light.danger : Colors.light.neutral200
|
||||
}]}>
|
||||
{currentStep === 'uploading' || currentStep === 'recognizing' ? (
|
||||
<ActivityIndicator size="small" color={Colors.light.onPrimary} />
|
||||
) : currentStep === 'completed' ? (
|
||||
<Ionicons name="checkmark" size={20} color={Colors.light.onPrimary} />
|
||||
) : currentStep === 'failed' ? (
|
||||
<Ionicons name="close" size={20} color={Colors.light.onPrimary} />
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
<View style={styles.progressInfo}>
|
||||
<Text style={styles.statusText}>{
|
||||
currentStep === 'idle' ? '准备中' :
|
||||
currentStep === 'uploading' ? '上传图片中' :
|
||||
currentStep === 'recognizing' ? 'AI 分析中' :
|
||||
currentStep === 'completed' ? '识别完成' :
|
||||
currentStep === 'failed' ? '识别失败' : ''
|
||||
}</Text>
|
||||
<Text style={styles.statusSubtext}>{
|
||||
currentStep === 'uploading' ? '正在将图片上传到云端处理...' :
|
||||
currentStep === 'recognizing' ? '智能模型正在分析食物成分...' :
|
||||
currentStep === 'completed' ? '即将跳转到分析结果页面' :
|
||||
currentStep === 'failed' ? '请检查网络连接或重新拍照' : ''
|
||||
}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 进度条 */}
|
||||
{(currentStep === 'uploading' || currentStep === 'recognizing') && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={styles.progressBarBackground}>
|
||||
<Animated.View
|
||||
style={[styles.progressBarFill, {
|
||||
width: progressAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
})
|
||||
}]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 识别日志卡片 */}
|
||||
<View style={styles.logCard}>
|
||||
<View style={styles.logHeader}>
|
||||
<Ionicons name="document-text-outline" size={18} color={Colors.light.primary} />
|
||||
<Text style={styles.logTitle}>进度</Text>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.logScrollView}
|
||||
contentContainerStyle={styles.logContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{recognitionLogs.map((log, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[styles.logItem, {
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateX: slideAnim }]
|
||||
}]}
|
||||
>
|
||||
<Text style={styles.logText}>{log}</Text>
|
||||
</Animated.View>
|
||||
))}
|
||||
{recognitionLogs.length === 0 && (
|
||||
<Text style={styles.logPlaceholder}>等待处理开始...</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 重试按钮 */}
|
||||
{currentStep === 'failed' && (
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={handleRetry}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="refresh" size={18} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>返回重新拍照</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取餐次标签
|
||||
function getMealTypeLabel(mealType: string): string {
|
||||
const mealTypeMap: Record<string, string> = {
|
||||
breakfast: '早餐',
|
||||
lunch: '午餐',
|
||||
dinner: '晚餐',
|
||||
snack: '加餐',
|
||||
};
|
||||
return mealTypeMap[mealType] || '未知餐次';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.light.pageBackgroundEmphasis,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
|
||||
// 照片卡片样式
|
||||
photoCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
photoFrame: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: Colors.light.neutral100,
|
||||
position: 'relative',
|
||||
},
|
||||
photoImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
mealTypeBadge: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: 'rgba(122, 90, 248, 0.95)',
|
||||
borderRadius: 20,
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
mealTypeBadgeText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
||||
// 信息卡片样式
|
||||
infoCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 24,
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 16,
|
||||
elevation: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.light.heroSurfaceTint,
|
||||
},
|
||||
infoHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
aiIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: Colors.light.heroSurfaceTint,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
infoDescription: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
lineHeight: 22,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
|
||||
// 按钮样式
|
||||
bottomContainer: {
|
||||
paddingBottom: 40,
|
||||
paddingTop: 8,
|
||||
},
|
||||
confirmButtonContainer: {},
|
||||
confirmButton: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
confirmButtonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
confirmButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
|
||||
// 识别过程容器
|
||||
recognitionContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// 缩略图卡片
|
||||
thumbnailCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
thumbnailImage: {
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 16,
|
||||
},
|
||||
thumbnailInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
thumbnailMealType: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderRadius: 16,
|
||||
marginBottom: 6,
|
||||
},
|
||||
thumbnailMealTypeText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
thumbnailTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
|
||||
// 进度卡片
|
||||
progressCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
progressHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
statusIconAnimated: {},
|
||||
statusIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
progressInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
marginBottom: 4,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
statusSubtext: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
|
||||
// 进度条
|
||||
progressBarContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
progressBarBackground: {
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: Colors.light.neutral100,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderRadius: 4,
|
||||
},
|
||||
|
||||
// 日志卡片
|
||||
logCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
flex: 1,
|
||||
minHeight: 200,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
logHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
logTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
marginLeft: 8,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
logScrollView: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.light.heroSurfaceTint,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
logContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
logItem: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
logText: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.text,
|
||||
lineHeight: 22,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
logPlaceholder: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textMuted,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
marginTop: 40,
|
||||
},
|
||||
|
||||
// 重试按钮
|
||||
retryButton: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
|
||||
// 通用样式
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.textMuted,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user