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:
imeepos
2025-10-31 17:53:56 +08:00
parent 668caaa91f
commit b9399bf4cf
38 changed files with 5641 additions and 1663 deletions

View 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
View 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;

View 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
View 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
View 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
View 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
View 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,
},
});

View 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
View 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',
},
});

View 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',
},
});

View 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
View 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',
},
});

View File

@@ -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>
);
}

View File

@@ -0,0 +1,2 @@
export { Button } from '../Button';
export { default as ButtonExample } from '../Button.example';

137
components/ui/button.tsx Normal file
View 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,
},
};