✨ feat: 应用界面重构 - 从模板中心到AI内容生成平台
- 重构首页:从模板列表展示改为AI功能展示界面 - 更新底部导航:将explore改为content,index改为home - 新增多个页面:积分、兑换、历史记录、结果展示、设置等 - 优化UI组件:新增通用按钮、分类标签、加载状态等组件 - 清理冗余文件:删除旧的认证文档和积分详情页面 - 更新主题配置:调整配色方案和视觉风格 - 修复导入路径:优化组件导入结构 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
200
components/Button.example.tsx
Normal file
200
components/Button.example.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, ScrollView, Text } from 'react-native';
|
||||
import Button from './Button';
|
||||
|
||||
const ButtonExample: React.FC = () => {
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
primary: false,
|
||||
secondary: false,
|
||||
outline: false,
|
||||
text: false,
|
||||
});
|
||||
|
||||
const toggleLoading = (variant: keyof typeof loadingStates) => {
|
||||
setLoadingStates(prev => ({
|
||||
...prev,
|
||||
[variant]: !prev[variant],
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePress = (variant: string) => {
|
||||
console.log(`Pressed ${variant} button`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Primary Button"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('primary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Secondary Button"
|
||||
variant="secondary"
|
||||
onPress={() => handlePress('secondary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Outline Button"
|
||||
variant="outline"
|
||||
onPress={() => handlePress('outline')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Text Button"
|
||||
variant="text"
|
||||
onPress={() => handlePress('text')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Small Button"
|
||||
size="small"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('small')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Medium Button"
|
||||
size="medium"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('medium')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Large Button"
|
||||
size="large"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('large')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Full Width Button"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onPress={() => handlePress('fullWidth')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Disabled Button"
|
||||
variant="primary"
|
||||
disabled
|
||||
onPress={() => handlePress('disabled')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Primary Loading"
|
||||
variant="primary"
|
||||
loading={loadingStates.primary}
|
||||
onPress={() => toggleLoading('primary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Secondary Loading"
|
||||
variant="secondary"
|
||||
loading={loadingStates.secondary}
|
||||
onPress={() => toggleLoading('secondary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Outline Loading"
|
||||
variant="outline"
|
||||
loading={loadingStates.outline}
|
||||
onPress={() => toggleLoading('outline')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Text Loading"
|
||||
variant="text"
|
||||
loading={loadingStates.text}
|
||||
onPress={() => toggleLoading('text')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Button with Icon"
|
||||
variant="primary"
|
||||
icon={
|
||||
<View style={styles.icon}>
|
||||
<Text style={styles.iconText}>⚡</Text>
|
||||
</View>
|
||||
}
|
||||
onPress={() => handlePress('withIcon')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Large Outline with Icon"
|
||||
variant="outline"
|
||||
size="large"
|
||||
icon={
|
||||
<View style={styles.icon}>
|
||||
<Text style={[styles.iconText, { color: '#007AFF' }]}>🚀</Text>
|
||||
</View>
|
||||
}
|
||||
onPress={() => handlePress('largeOutlineWithIcon')}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E0E0E0',
|
||||
marginVertical: 24,
|
||||
},
|
||||
icon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonExample;
|
||||
155
components/Button.tsx
Normal file
155
components/Button.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
} from 'react-native';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Animation } from '@/constants/theme';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text';
|
||||
type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
primary: {
|
||||
backgroundColor: Colors.brand.primary,
|
||||
borderWidth: 0,
|
||||
borderColor: 'transparent',
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: Colors.brand.secondary,
|
||||
borderWidth: 0,
|
||||
borderColor: 'transparent',
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.brand.primary,
|
||||
textColor: Colors.brand.primary,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
borderColor: 'transparent',
|
||||
textColor: Colors.brand.primary,
|
||||
},
|
||||
},
|
||||
sizes: {
|
||||
small: {
|
||||
height: 36,
|
||||
paddingHorizontal: Spacing.md,
|
||||
fontSize: FontSize.sm,
|
||||
},
|
||||
medium: {
|
||||
height: 48,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
fontSize: FontSize.md,
|
||||
},
|
||||
large: {
|
||||
height: 56,
|
||||
paddingHorizontal: Spacing.xxl,
|
||||
fontSize: FontSize.lg,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
icon,
|
||||
style,
|
||||
}) => {
|
||||
const variantConfig = config.variants[variant];
|
||||
const sizeConfig = config.sizes[size];
|
||||
const isInteractive = !disabled && !loading;
|
||||
|
||||
const buttonStyle = [
|
||||
styles.base,
|
||||
{
|
||||
height: sizeConfig.height,
|
||||
paddingHorizontal: sizeConfig.paddingHorizontal,
|
||||
},
|
||||
{
|
||||
backgroundColor: variantConfig.backgroundColor,
|
||||
borderWidth: variantConfig.borderWidth,
|
||||
borderColor: variantConfig.borderColor,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
},
|
||||
fullWidth && styles.fullWidth,
|
||||
style,
|
||||
];
|
||||
|
||||
const textStyle = [
|
||||
styles.text,
|
||||
{
|
||||
color: variantConfig.textColor,
|
||||
fontSize: sizeConfig.fontSize,
|
||||
},
|
||||
];
|
||||
|
||||
const indicatorColor = variant === 'outline' || variant === 'text'
|
||||
? '#007AFF'
|
||||
: '#FFFFFF';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={!isInteractive}
|
||||
style={({ pressed }) => [
|
||||
buttonStyle,
|
||||
isInteractive && pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<>{icon}</>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={indicatorColor} />
|
||||
) : (
|
||||
<Text style={textStyle}>{title}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: BorderRadius.full,
|
||||
fontWeight: '600',
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
pressed: {
|
||||
transform: [{ scale: Animation.scale.pressed }],
|
||||
},
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default Button;
|
||||
64
components/CategoryTabs.example.tsx
Normal file
64
components/CategoryTabs.example.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ExampleScreen: React.FC = () => {
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string>('all');
|
||||
|
||||
const categories: Category[] = [
|
||||
{ id: 'all', name: '全部' },
|
||||
{ id: 'finance', name: '财经' },
|
||||
{ id: 'entertainment', name: '娱乐' },
|
||||
{ id: 'education', name: '教育' },
|
||||
{ id: 'technology', name: '科技' },
|
||||
{ id: 'sports', name: '体育' },
|
||||
{ id: 'lifestyle', name: '生活' },
|
||||
{ id: 'travel', name: '旅行' },
|
||||
{ id: 'health', name: '健康' },
|
||||
{ id: 'food', name: '美食' },
|
||||
];
|
||||
|
||||
const handleCategoryChange = (category: Category) => {
|
||||
console.log('切换到分类:', category);
|
||||
setActiveCategoryId(category.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CategoryTabs
|
||||
categories={categories}
|
||||
activeId={activeCategoryId}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.contentText}>
|
||||
当前选中: {categories.find(cat => cat.id === activeCategoryId)?.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
contentText: {
|
||||
fontSize: 16,
|
||||
color: '#333333',
|
||||
},
|
||||
});
|
||||
|
||||
export default ExampleScreen;
|
||||
162
components/CategoryTabs.tsx
Normal file
162
components/CategoryTabs.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
useDerivedValue,
|
||||
} from 'react-native-reanimated';
|
||||
import { Colors, Spacing, FontSize, Animation } from '@/constants/theme';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CategoryTabsProps {
|
||||
categories?: Category[];
|
||||
activeId?: string;
|
||||
onChange?: (category: Category) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORIES: Category[] = [
|
||||
{ id: 'all', name: '全部' },
|
||||
{ id: 'finance', name: '财经' },
|
||||
{ id: 'entertainment', name: '娱乐' },
|
||||
{ id: 'education', name: '教育' },
|
||||
{ id: 'technology', name: '科技' },
|
||||
{ id: 'sports', name: '体育' },
|
||||
{ id: 'lifestyle', name: '生活' },
|
||||
{ id: 'travel', name: '旅行' },
|
||||
];
|
||||
|
||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
categories = DEFAULT_CATEGORIES,
|
||||
activeId = 'all',
|
||||
onChange,
|
||||
style,
|
||||
}) => {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const activeIndex = useMemo(() => {
|
||||
return categories.findIndex(cat => cat.id === activeId);
|
||||
}, [categories, activeId]);
|
||||
|
||||
const indicatorPosition = useDerivedValue(() => {
|
||||
if (categories.length === 0) return 0;
|
||||
|
||||
const activeIndexSafe = activeIndex >= 0 ? activeIndex : 0;
|
||||
const padding = 16;
|
||||
const gap = 8;
|
||||
let offset = padding;
|
||||
|
||||
for (let i = 0; i < activeIndexSafe; i++) {
|
||||
const item = categories[i];
|
||||
const itemWidth = Math.min(
|
||||
Math.max(44, item.name.length * 16 + 32),
|
||||
screenWidth / 3
|
||||
);
|
||||
offset += itemWidth + gap;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}, [activeIndex, categories, screenWidth]);
|
||||
|
||||
const activeCategory = categories[activeIndex] || categories[0];
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => {
|
||||
if (!activeCategory) {
|
||||
return { width: 0, left: 0 };
|
||||
}
|
||||
|
||||
const width = Math.min(
|
||||
Math.max(44, activeCategory.name.length * 16 + 32),
|
||||
screenWidth / 3
|
||||
);
|
||||
|
||||
return {
|
||||
width: withTiming(width, { duration: Animation.duration.normal }),
|
||||
left: withTiming(indicatorPosition.value, { duration: Animation.duration.normal }),
|
||||
};
|
||||
});
|
||||
|
||||
const renderCategory = (category: Category, index: number) => {
|
||||
const isActive = category.id === activeId;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={styles.tabItem}
|
||||
onPress={() => onChange?.(category)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, isActive && styles.activeTabText]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
decelerationRate="fast"
|
||||
snapToInterval={1}
|
||||
>
|
||||
{categories.map(renderCategory)}
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View style={[styles.indicator, indicatorStyle]} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: Colors.background.secondary,
|
||||
width: '100%',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
tabItem: {
|
||||
minWidth: 44,
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabText: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
color: Colors.brand.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
backgroundColor: Colors.brand.primary,
|
||||
borderRadius: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(CategoryTabs);
|
||||
105
components/CommonHeader.tsx
Normal file
105
components/CommonHeader.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Colors, Spacing, FontSize, Layout } from '@/constants/theme';
|
||||
|
||||
interface CommonHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
rightContent?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export const CommonHeader: React.FC<CommonHeaderProps> = ({
|
||||
title,
|
||||
showBack = true,
|
||||
onBack,
|
||||
rightContent,
|
||||
backgroundColor = '#FFFFFF',
|
||||
}) => {
|
||||
const handleBackPress = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.history) {
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor }]}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.leftArea}>
|
||||
{showBack && (
|
||||
<TouchableOpacity
|
||||
onPress={handleBackPress}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ArrowLeft size={24} color="#007AFF" strokeWidth={2.5} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.centerArea}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.rightArea}>
|
||||
{rightContent}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: Layout.headerHeight,
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: Colors.border,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: Spacing.md,
|
||||
},
|
||||
leftArea: {
|
||||
width: 40,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
centerArea: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.lg,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
rightArea: {
|
||||
width: 40,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
});
|
||||
|
||||
export default CommonHeader;
|
||||
105
components/ErrorRetry.tsx
Normal file
105
components/ErrorRetry.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, StyleProp, ViewStyle } from 'react-native';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
interface ErrorRetryProps {
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
subMessage?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
showIcon?: boolean;
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
export function ErrorRetry({
|
||||
message,
|
||||
onRetry,
|
||||
subMessage,
|
||||
style,
|
||||
showIcon = true,
|
||||
buttonText = '重试',
|
||||
}: ErrorRetryProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.content}>
|
||||
{showIcon && (
|
||||
<View style={styles.iconContainer}>
|
||||
<AlertCircle size={48} color={Colors.brand.danger} strokeWidth={1.5} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.title}>{message}</Text>
|
||||
|
||||
{subMessage && <Text style={styles.subtitle}>{subMessage}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onRetry}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<RefreshCw size={18} color={Colors.brand.primary} style={styles.buttonIcon} />
|
||||
<Text style={styles.buttonText}>{buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: Colors.background.secondary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: Spacing.xl,
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: Colors.background.tertiary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.lg,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
textAlign: 'center',
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: Colors.background.tertiary,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.full,
|
||||
...Shadow.small,
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
color: Colors.brand.primary,
|
||||
},
|
||||
});
|
||||
|
||||
export default ErrorRetry;
|
||||
130
components/GiftCard.tsx
Normal file
130
components/GiftCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { View, StyleSheet, StyleProp, type ViewStyle } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
export interface GiftCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
points: number;
|
||||
onExchange?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function GiftCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
points,
|
||||
onExchange,
|
||||
style,
|
||||
}: GiftCardProps) {
|
||||
const cardBackgroundColor = useThemeColor(
|
||||
{ light: '#fff', dark: '#1F2223' },
|
||||
'card'
|
||||
);
|
||||
const borderColor = useThemeColor(
|
||||
{ light: '#e0e0e0', dark: '#2C2E2F' },
|
||||
'cardBorder'
|
||||
);
|
||||
const pointsColor = '#FF6B6B';
|
||||
const descriptionColor = useThemeColor(
|
||||
{ light: '#666666', dark: '#9BA1A6' },
|
||||
'text'
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedView
|
||||
style={[
|
||||
styles.card,
|
||||
{ backgroundColor: cardBackgroundColor, borderColor },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<ThemedText style={styles.icon}>{icon}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<ThemedText style={[styles.description, { color: descriptionColor }]}>
|
||||
{description}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.pointsContainer}>
|
||||
<ThemedText style={[styles.points, { color: pointsColor }]}>
|
||||
{points.toLocaleString()}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.pointsLabel, { color: descriptionColor }]}>
|
||||
积分
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Button variant="primary" size="md" onPress={onExchange}>
|
||||
兑换
|
||||
</Button>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: BorderRadius.md,
|
||||
padding: Spacing.md,
|
||||
borderWidth: 1,
|
||||
...Shadow.small,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: Colors.background.tertiary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
description: {
|
||||
fontSize: FontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
pointsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
points: {
|
||||
fontSize: FontSize.lg,
|
||||
fontWeight: '700',
|
||||
marginRight: Spacing.xs,
|
||||
},
|
||||
pointsLabel: {
|
||||
fontSize: FontSize.xs,
|
||||
},
|
||||
});
|
||||
49
components/HistoryCard.example.tsx
Normal file
49
components/HistoryCard.example.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { ScrollView } from 'react-native';
|
||||
import { HistoryCard } from './HistoryCard';
|
||||
|
||||
export function HistoryCardExample() {
|
||||
const mockData = [
|
||||
{
|
||||
templateName: 'AI视频生成模板',
|
||||
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||
preview: '生成了一个关于科技产品的宣传视频...',
|
||||
status: 'completed' as const,
|
||||
},
|
||||
{
|
||||
templateName: '图片处理模板',
|
||||
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'running' as const,
|
||||
},
|
||||
{
|
||||
templateName: '文本摘要模板',
|
||||
createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(),
|
||||
preview: '对长篇文章进行了智能摘要,提取了核心观点和关键信息...',
|
||||
status: 'failed' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const handleView = (index: number) => {
|
||||
console.log('查看详情:', index);
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
console.log('删除:', index);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{mockData.map((item, index) => (
|
||||
<HistoryCard
|
||||
key={index}
|
||||
templateName={item.templateName}
|
||||
createdAt={item.createdAt}
|
||||
preview={item.preview}
|
||||
status={item.status}
|
||||
onView={() => handleView(index)}
|
||||
onDelete={() => handleDelete(index)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
200
components/HistoryCard.tsx
Normal file
200
components/HistoryCard.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
type HistoryStatus = 'running' | 'completed' | 'failed';
|
||||
|
||||
interface HistoryCardProps {
|
||||
templateName: string;
|
||||
createdAt: string;
|
||||
preview?: string;
|
||||
status: HistoryStatus;
|
||||
onView?: () => void;
|
||||
onDelete?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function HistoryCard({
|
||||
templateName,
|
||||
createdAt,
|
||||
preview,
|
||||
status,
|
||||
onView,
|
||||
onDelete,
|
||||
style,
|
||||
}: HistoryCardProps) {
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const now = new Date();
|
||||
const past = new Date(dateString);
|
||||
const diffInMinutes = Math.floor((now.getTime() - past.getTime()) / 60000);
|
||||
|
||||
if (diffInMinutes < 1) return '刚刚';
|
||||
if (diffInMinutes < 60) return `${diffInMinutes}分钟前`;
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) return `${diffInHours}小时前`;
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 30) return `${diffInDays}天前`;
|
||||
|
||||
return past.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: HistoryStatus) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
color: Colors.brand.secondary,
|
||||
text: '进行中',
|
||||
bgColor: '#E8F8F7',
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
color: Colors.brand.success,
|
||||
text: '已完成',
|
||||
bgColor: '#E8F5E9',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
color: Colors.brand.danger,
|
||||
text: '失败',
|
||||
bgColor: '#FFEBEB',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.container, style]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.templateName}>{templateName}</Text>
|
||||
<Text style={styles.timeText}>{formatTimeAgo(createdAt)}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{preview ? (
|
||||
<Text style={styles.previewText} numberOfLines={2}>
|
||||
{preview}
|
||||
</Text>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: statusConfig.bgColor },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.statusText, { color: statusConfig.color }]}>
|
||||
{statusConfig.text}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={styles.viewButton}
|
||||
onPress={onView}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.viewButtonText}>查看详情</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={onDelete}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.deleteButtonText}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: Colors.background.secondary,
|
||||
borderRadius: BorderRadius.md,
|
||||
padding: Spacing.md,
|
||||
marginVertical: Spacing.sm,
|
||||
marginHorizontal: Spacing.md,
|
||||
...Shadow.small,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
templateName: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
flex: 1,
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: FontSize.xs,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'right',
|
||||
},
|
||||
content: {
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
previewText: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.secondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: BorderRadius.full,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: FontSize.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
viewButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: Spacing.md,
|
||||
borderRadius: BorderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.brand.primary,
|
||||
alignItems: 'center',
|
||||
},
|
||||
viewButtonText: {
|
||||
color: Colors.brand.primary,
|
||||
fontSize: FontSize.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
deleteButton: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: Spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: Colors.brand.danger,
|
||||
fontSize: FontSize.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
48
components/LoadingState.tsx
Normal file
48
components/LoadingState.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ActivityIndicator, View, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native';
|
||||
import { Colors, Spacing, FontSize } from '@/constants/theme';
|
||||
|
||||
export interface LoadingStateProps {
|
||||
text?: string;
|
||||
subText?: string;
|
||||
size?: 'small' | 'large';
|
||||
color?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
text = '加载中...',
|
||||
subText,
|
||||
size = 'large',
|
||||
color = Colors.brand.primary,
|
||||
style,
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ActivityIndicator size={size} color={color} />
|
||||
{text && <Text style={styles.text}>{text}</Text>}
|
||||
{subText && <Text style={styles.subText}>{subText}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: Colors.background.secondary,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
},
|
||||
text: {
|
||||
marginTop: Spacing.lg,
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.secondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subText: {
|
||||
marginTop: Spacing.sm,
|
||||
fontSize: FontSize.xs,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
137
components/ProgressIndicator.tsx
Normal file
137
components/ProgressIndicator.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
currentStep: number;
|
||||
totalSteps?: number;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({
|
||||
currentStep,
|
||||
totalSteps = 2,
|
||||
steps,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.progressBar}>
|
||||
{Array.from({ length: totalSteps - 1 }, (_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.connector,
|
||||
currentStep > index + 1 && styles.connectorActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.stepsContainer}>
|
||||
{steps.map((step, index) => (
|
||||
<View key={index} style={styles.stepItem}>
|
||||
<View
|
||||
style={[
|
||||
styles.stepCircle,
|
||||
currentStep > index + 1 && styles.stepCircleCompleted,
|
||||
currentStep === index + 1 && styles.stepCircleActive,
|
||||
]}
|
||||
>
|
||||
{currentStep > index + 1 ? (
|
||||
<Text style={styles.checkMark}>✓</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.stepNumber,
|
||||
currentStep >= index + 1 && styles.stepNumberActive,
|
||||
]}
|
||||
>
|
||||
{index + 1}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.stepLabel,
|
||||
currentStep >= index + 1 && styles.stepLabelActive,
|
||||
]}
|
||||
>
|
||||
{step}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
progressBar: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 32,
|
||||
},
|
||||
connector: {
|
||||
flex: 1,
|
||||
height: 2,
|
||||
backgroundColor: '#E5E5EA',
|
||||
marginHorizontal: 8,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
connectorActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
stepsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
stepItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
stepCircle: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F2F2F7',
|
||||
borderWidth: 2,
|
||||
borderColor: '#E5E5EA',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
stepCircleActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
stepCircleCompleted: {
|
||||
backgroundColor: '#4ECDC4',
|
||||
borderColor: '#4ECDC4',
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#8E8E93',
|
||||
},
|
||||
stepNumberActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
checkMark: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8E8E93',
|
||||
textAlign: 'center',
|
||||
},
|
||||
stepLabelActive: {
|
||||
color: '#007AFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default ProgressIndicator;
|
||||
105
components/empty-state.tsx
Normal file
105
components/empty-state.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
} from 'react-native';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionText?: string;
|
||||
onAction?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon = '📄',
|
||||
title,
|
||||
description,
|
||||
actionText,
|
||||
onAction,
|
||||
style,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.content}>
|
||||
<ThemedText style={styles.icon}>{icon}</ThemedText>
|
||||
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
|
||||
{description ? (
|
||||
<ThemedText style={styles.description}>
|
||||
{description}
|
||||
</ThemedText>
|
||||
) : null}
|
||||
|
||||
{actionText && onAction ? (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={onAction}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionText}>
|
||||
{actionText}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: Colors.background.secondary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: Spacing.xxl,
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 48,
|
||||
lineHeight: 64,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
textAlign: 'center',
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
description: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
actionButton: {
|
||||
backgroundColor: Colors.brand.primary,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.md,
|
||||
minWidth: 120,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...Shadow.small,
|
||||
},
|
||||
actionText: {
|
||||
color: '#ffffff',
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -63,8 +63,11 @@ export function TemplateCard({
|
||||
};
|
||||
|
||||
const handleCardPress = () => {
|
||||
// 统一处理:打开媒体全屏预览
|
||||
handleFullscreenMedia();
|
||||
if (onPress) {
|
||||
onPress(template);
|
||||
} else {
|
||||
handleFullscreenMedia();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前媒体模板
|
||||
@@ -75,7 +78,11 @@ export function TemplateCard({
|
||||
const currentTemplate = getCurrentMediaTemplate();
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { backgroundColor: cardColor }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: cardColor }]}
|
||||
onPress={handleCardPress}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View style={styles.mediaContainer}>
|
||||
<ThemedView style={[styles.mediaContent, { height: mediaHeight }]}>
|
||||
{isVideo ? (
|
||||
@@ -94,7 +101,7 @@ export function TemplateCard({
|
||||
onPress={handleCardPress}
|
||||
/>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handleCardPress} activeOpacity={0.9} style={styles.imageContainer}>
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{ uri: template.coverImageUrl }}
|
||||
style={[
|
||||
@@ -105,7 +112,7 @@ export function TemplateCard({
|
||||
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
|
||||
transition={200}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{template.category && (
|
||||
@@ -154,7 +161,7 @@ export function TemplateCard({
|
||||
templates={allTemplates}
|
||||
onIndexChanged={handleMediaIndexChanged}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
2
components/ui/button-index.ts
Normal file
2
components/ui/button-index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Button } from '../Button';
|
||||
export { default as ButtonExample } from '../Button.example';
|
||||
137
components/ui/button.tsx
Normal file
137
components/ui/button.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
type PressableProps,
|
||||
type TextStyle,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends PressableProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: React.ReactNode;
|
||||
textStyle?: TextStyle;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
style,
|
||||
textStyle,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const backgroundColor = useThemeColor(
|
||||
variantStyles[variant].background,
|
||||
'tint'
|
||||
);
|
||||
const borderColor = useThemeColor(
|
||||
variantStyles[variant].border,
|
||||
'tint'
|
||||
);
|
||||
const textColor = useThemeColor(
|
||||
variantStyles[variant].text,
|
||||
'tint'
|
||||
);
|
||||
|
||||
const dynamicStyles = {
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
borderWidth: variantStyles[variant].borderWidth,
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.base,
|
||||
styles[size],
|
||||
dynamicStyles,
|
||||
style,
|
||||
] as any}
|
||||
{...props}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.text,
|
||||
styles[`${size}Text`],
|
||||
{ color: textColor },
|
||||
variant === 'ghost' && { paddingHorizontal: 0, paddingVertical: 0 },
|
||||
textStyle,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sm: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
minHeight: 36,
|
||||
},
|
||||
md: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
minHeight: 44,
|
||||
},
|
||||
lg: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
minHeight: 52,
|
||||
},
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
smText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
mdText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
lgText: {
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const variantStyles = {
|
||||
primary: {
|
||||
background: { light: '#0a7ea4', dark: '#fff' },
|
||||
text: { light: '#fff', dark: '#000' },
|
||||
border: { light: 'transparent', dark: 'transparent' },
|
||||
borderWidth: 0,
|
||||
},
|
||||
secondary: {
|
||||
background: { light: '#f0f0f0', dark: '#2C2E2F' },
|
||||
text: { light: '#11181C', dark: '#ECEDEE' },
|
||||
border: { light: 'transparent', dark: 'transparent' },
|
||||
borderWidth: 0,
|
||||
},
|
||||
outline: {
|
||||
background: { light: 'transparent', dark: 'transparent' },
|
||||
text: { light: '#0a7ea4', dark: '#fff' },
|
||||
border: { light: '#0a7ea4', dark: '#fff' },
|
||||
borderWidth: 1,
|
||||
},
|
||||
ghost: {
|
||||
background: { light: 'transparent', dark: 'transparent' },
|
||||
text: { light: '#0a7ea4', dark: '#fff' },
|
||||
border: { light: 'transparent', dark: 'transparent' },
|
||||
borderWidth: 0,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user