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

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@ expo-env.d.ts
*.p12
*.key
*.mobileprovision
design
.claude
# Metro
.metro-health-check*

View File

@@ -4,8 +4,8 @@ import React from 'react';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useAuth } from '@/hooks/use-auth';
import { useColorScheme } from '@/hooks/use-color-scheme';
export default function TabLayout() {
const colorScheme = useColorScheme();
@@ -23,16 +23,16 @@ export default function TabLayout() {
tabBarButton: HapticTab,
}}>
<Tabs.Screen
name="index"
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
name="history"
options={{
title: 'Explore',
title: 'Content',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>

View File

@@ -1,205 +0,0 @@
import { useMemo, useState } from 'react';
import { ScrollView, StyleSheet, View, TouchableOpacity, Text } from 'react-native';
import {
CategoryTabs,
CommunityGrid,
FeatureCarousel,
Header,
PageLayout,
SectionHeader,
StatusBarSpacer,
type CommunityItem,
type FeatureItem,
} from '@/components/bestai';
import { useAuth } from '@/hooks/use-auth';
import { useAuthGuard } from '@/hooks/use-auth-guard';
const categories = [
{ id: 'visual-effects', label: 'Visual Effects' },
{ id: 'higgsfield-soul', label: 'Higgsfield Soul' },
{ id: 'higgsfield-apps', label: 'Higgsfield Apps' },
{ id: 'king', label: 'King' },
];
const baseFeatureItems: FeatureItem[] = [
{
id: 'sketch-video',
title: 'UNLIMITED SKETCH TO VIDEO',
subtitle: 'Bring your imagination to life with Sora 2',
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80',
},
{
id: 'portrait-video',
title: 'UNLIMITED PORTRAIT TO VIDEO',
subtitle: 'Transform portraits into motion-driven stories',
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
},
{
id: 'water-simulation',
title: 'FLUID SIMULATION PACK',
subtitle: 'Realistic water physics for cinematic scenes',
image: 'https://images.unsplash.com/photo-1505744386214-51dba16a26fc?auto=format&fit=crop&w=800&q=80',
},
];
const baseCommunityItems: CommunityItem[] = [
{
id: 'community-1',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
{
id: 'community-2',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
{
id: 'community-3',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
{
id: 'community-4',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1531259683007-016a7b628fc4?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
];
export default function ExploreScreen() {
const { isAuthenticated } = useAuth();
const { requireAuth } = useAuthGuard();
const [activeCategory, setActiveCategory] = useState(categories[0]?.id ?? 'visual-effects');
const featureItems = useMemo(() => {
return baseFeatureItems.map((item, index) => ({
...item,
id: `${activeCategory}-${index}`,
}));
}, [activeCategory]);
const communityItems = useMemo(() => {
return baseCommunityItems.map((item, index) => ({
...item,
id: `${activeCategory}-${index}`,
}));
}, [activeCategory]);
const handleGeneratePress = () => {
requireAuth(() => {
console.log('用户已登录,执行生成操作');
});
};
if (!isAuthenticated) {
return (
<PageLayout backgroundColor="#050505">
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
<StatusBarSpacer />
<Header />
<View style={styles.loginPrompt}>
<View style={styles.lockIconContainer}>
<Text style={styles.lockIcon}>🔒</Text>
</View>
<Text style={styles.loginTitle}></Text>
<Text style={styles.loginSubtitle}>
使
</Text>
<TouchableOpacity
style={styles.loginButton}
onPress={() => requireAuth()}
activeOpacity={0.8}
>
<Text style={styles.loginButtonText}></Text>
</TouchableOpacity>
</View>
<View style={styles.bottomSpacer} />
</ScrollView>
</PageLayout>
);
}
return (
<PageLayout backgroundColor="#050505">
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
<StatusBarSpacer />
<Header />
<CategoryTabs categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
<FeatureCarousel items={featureItems} />
<SectionHeader title="WAN 2.5 COMMUNITY" />
<CommunityGrid items={communityItems} />
<View style={styles.bottomSpacer} />
</ScrollView>
</PageLayout>
);
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
contentContainer: {
paddingTop: 16,
paddingBottom: 48,
},
bottomSpacer: {
height: 32,
},
loginPrompt: {
alignItems: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
},
lockIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
lockIcon: {
fontSize: 40,
},
loginTitle: {
fontSize: 24,
fontWeight: '700',
color: '#ffffff',
marginBottom: 12,
},
loginSubtitle: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.6)',
textAlign: 'center',
lineHeight: 24,
marginBottom: 32,
},
loginButton: {
backgroundColor: '#d7ff1f',
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 12,
},
loginButtonText: {
color: '#050505',
fontSize: 16,
fontWeight: '700',
},
});

149
app/(tabs)/history.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { ThemedView } from '@/components/themed-view';
import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
type GalleryLane = 'leading' | 'trailing';
interface GalleryTile {
id: string;
lane: GalleryLane;
uri: string;
height: number;
}
const curatedGallery: GalleryTile[] = [
{
id: 'ember-circle',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1616004655122-818af0f3efc6?auto=format&fit=crop&w=1200&q=80',
height: 232,
},
{
id: 'ocean-horizon',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=1200&q=80',
height: 152,
},
{
id: 'studio-poise',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1582096897407-9b6c86e1a8d3?auto=format&fit=crop&w=1200&q=80',
height: 232,
},
{
id: 'duo-portrait',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1601040122900-86ad461b8234?auto=format&fit=crop&w=1200&q=80',
height: 148,
},
{
id: 'sage-armor',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1200&q=80',
height: 224,
},
{
id: 'velvet-repose',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1515378791036-0648a3ef77b2?auto=format&fit=crop&w=1200&q=80',
height: 188,
},
{
id: 'nocturne-lounge',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1573497491208-6b1acb260507?auto=format&fit=crop&w=1200&q=80',
height: 184,
},
{
id: 'sunlit-duet',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1531257240576-a4a0ef4af1c8?auto=format&fit=crop&w=1200&q=80',
height: 188,
},
];
const leadingNarratives = curatedGallery.filter(tile => tile.lane === 'leading');
const trailingNarratives = curatedGallery.filter(tile => tile.lane === 'trailing');
export default function HistoryScreen() {
return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<StatusBar style="light" />
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<Text style={styles.heading}>Content Generation</Text>
<Text style={styles.dateline}>10.19 Wednesday</Text>
<View style={styles.gallery}>
<View style={styles.leadingLane}>
{leadingNarratives.map(tile => (
<Image
key={tile.id}
source={{ uri: tile.uri }}
style={[styles.frame, { height: tile.height }]}
/>
))}
</View>
<View style={styles.trailingLane}>
{trailingNarratives.map(tile => (
<Image
key={tile.id}
source={{ uri: tile.uri }}
style={[styles.frame, { height: tile.height }]}
/>
))}
</View>
</View>
</ScrollView>
</SafeAreaView>
</ThemedView>
);
}
const styles = StyleSheet.create({
canvas: {
flex: 1,
},
safeArea: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 24,
paddingTop: 24,
paddingBottom: 48,
},
heading: {
fontSize: 24,
fontWeight: '600',
color: '#FFFFFF',
textAlign: 'center',
letterSpacing: 0.4,
},
dateline: {
marginTop: 16,
fontSize: 16,
fontWeight: '500',
color: '#EDEDED',
},
gallery: {
flexDirection: 'row',
marginTop: 24,
},
leadingLane: {
flex: 1,
paddingRight: 12,
},
trailingLane: {
flex: 1,
paddingLeft: 12,
},
frame: {
width: '100%',
borderRadius: 28,
marginBottom: 16,
},
});

View File

@@ -1,349 +1,139 @@
import { TemplateList } from '@/components/templates/template-list';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { useMemo, useState } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import {
CategoryTabs,
CommunityGrid,
FeatureCarousel,
Header,
PageLayout,
SectionHeader,
StatusBarSpacer,
type CommunityItem,
type FeatureItem,
} from '@/components/bestai';
import type { FeatureItem as FeatureItemType } from '@/components/bestai/feature-carousel';
import { useAuth } from '@/hooks/use-auth';
import { getTemplates } from '@/lib/api/templates';
import { Template } from '@/lib/types/template';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LoginModal } from '@/components/auth/login-modal';
import { useAuthGuard } from '@/hooks/use-auth-guard';
export default function HomeScreen() {
const categories = [
{ id: 'visual-effects', label: 'Visual Effects' },
{ id: 'higgsfield-soul', label: 'Higgsfield Soul' },
{ id: 'higgsfield-apps', label: 'Higgsfield Apps' },
{ id: 'king', label: 'King' },
];
const baseFeatureItems: FeatureItem[] = [
{
id: 'sketch-video',
title: 'UNLIMITED SKETCH TO VIDEO',
subtitle: 'Bring your imagination to life with Sora 2',
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80',
},
{
id: 'portrait-video',
title: 'UNLIMITED PORTRAIT TO VIDEO',
subtitle: 'Transform portraits into motion-driven stories',
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
},
{
id: 'water-simulation',
title: 'FLUID SIMULATION PACK',
subtitle: 'Realistic water physics for cinematic scenes',
image: 'https://images.unsplash.com/photo-1505744386214-51dba16a26fc?auto=format&fit=crop&w=800&q=80',
},
];
const baseCommunityItems: CommunityItem[] = [
{
id: 'community-1',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
{
id: 'community-2',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
{
id: 'community-3',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
{
id: 'community-4',
chip: '64/10',
title: 'OBJECTS AROUND',
image: 'https://images.unsplash.com/photo-1531259683007-016a7b628fc4?auto=format&fit=crop&w=800&q=80',
actionLabel: 'Generate',
},
];
export default function ExploreScreen() {
const { isAuthenticated } = useAuth();
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showLoginModal, setShowLoginModal] = useState(false);
const pulseAnim = useRef(new Animated.Value(0)).current;
const bounceAnim = useRef(new Animated.Value(0)).current;
const { requireAuth } = useAuthGuard();
const [activeCategory, setActiveCategory] = useState(categories[0]?.id ?? 'visual-effects');
const fetchTemplates = useCallback(async (page: number = 1, isRefreshing = false, isLoadMore = false) => {
if (!isAuthenticated) return;
const featureItems = useMemo(() => {
return baseFeatureItems.map((item, index) => ({
...item,
id: `${activeCategory}-${index}`,
}));
}, [activeCategory]);
try {
if (isRefreshing) {
setRefreshing(true);
} else if (isLoadMore) {
setIsLoadingMore(true);
} else {
setLoading(true);
}
setError(null);
const communityItems = useMemo(() => {
return baseCommunityItems.map((item, index) => ({
...item,
id: `${activeCategory}-${index}`,
}));
}, [activeCategory]);
const response = await getTemplates({
page,
size: 10,
status: 'RELEASE'
});
if (isRefreshing || page === 1) {
setTemplates(response.data);
setCurrentPage(1);
} else {
setTemplates(prev => [...prev, ...response.data]);
setCurrentPage(page);
}
setHasMore(response.pagination.page < response.pagination.totalPages);
} catch (err: any) {
console.error('获取模板列表失败:', err);
setError(err.message || '获取模板列表失败');
} finally {
setLoading(false);
setRefreshing(false);
setIsLoadingMore(false);
}
}, [isAuthenticated]);
const loadMore = useCallback(() => {
if (!isLoadingMore && hasMore && !refreshing) {
fetchTemplates(currentPage + 1, false, true);
}
}, [currentPage, hasMore, isLoadingMore, refreshing, fetchTemplates]);
useEffect(() => {
if (isAuthenticated) {
fetchTemplates();
setShowLoginModal(false);
}
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: false,
}),
Animated.timing(pulseAnim, {
toValue: 0,
duration: 2000,
useNativeDriver: false,
}),
])
).start();
Animated.loop(
Animated.sequence([
Animated.timing(bounceAnim, {
toValue: -10,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(bounceAnim, {
toValue: -5,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(bounceAnim, {
toValue: 0,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(bounceAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
])
).start();
}, [isAuthenticated, fetchTemplates, pulseAnim, bounceAnim]);
const handleRefresh = useCallback(() => {
fetchTemplates(1, true, false);
}, [fetchTemplates]);
const handleTemplatePress = (template: Template) => {
Alert.alert('模板详情', `您选择了: ${template.title}`);
};
const handleVideoChange = (template: Template, index: number) => {
console.log('视频切换到:', template.title, '索引:', index);
};
if (!isAuthenticated) {
const scaleAnim = pulseAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.95, 1.05],
const handleGeneratePress = () => {
requireAuth(() => {
console.log('用户已登录,执行生成操作');
});
};
return (
<ThemedView style={styles.container}>
<View style={styles.welcomeContainer}>
<Animated.View
style={[
styles.iconContainer,
{ transform: [{ scale: scaleAnim }] },
]}
>
<Ionicons name="albums-outline" size={80} color="#007AFF" />
</Animated.View>
<ThemedText type="title" style={styles.welcomeTitle}>
使
</ThemedText>
<ThemedText style={styles.welcomeSubtitle}>
使
</ThemedText>
<TouchableOpacity
style={styles.loginButton}
onPress={() => setShowLoginModal(true)}
activeOpacity={0.8}
>
<LinearGradient
colors={['#007AFF', '#0056D2']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.loginButtonGradient}
>
<Ionicons name="log-in-outline" size={20} color="#fff" style={styles.loginButtonIcon} />
<Text style={styles.loginButtonText}></Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity style={styles.guestButton} activeOpacity={0.6}>
<Text style={styles.guestButtonText}></Text>
</TouchableOpacity>
</View>
<LoginModal
visible={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
</ThemedView>
);
}
const shadowOpacity = pulseAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 0.4],
});
const handleFeaturePress = (item: FeatureItemType) => {
requireAuth(() => {
console.log('用户已登录,使用功能:', item.title);
});
};
return (
<ThemedView style={styles.container}>
<Animated.View style={[styles.headerBanner, { shadowOpacity }]}>
<LinearGradient
colors={['#ff6b6b', '#ff8e53', '#ff6b35']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradientBanner}
>
<Text style={styles.bannerText}>
New User 50% Off Coupon : <Text style={styles.couponText}>NEWUSER</Text>
</Text>
<Animated.Text
style={[
styles.giftIcon,
{ transform: [{ translateY: bounceAnim }] },
]}
>
🎁
</Animated.Text>
</LinearGradient>
</Animated.View>
<TemplateList
templates={templates}
loading={loading}
refreshing={refreshing}
isLoadingMore={isLoadingMore}
hasMore={hasMore}
onRefresh={handleRefresh}
onLoadMore={loadMore}
onTemplatePress={handleTemplatePress}
onVideoChange={handleVideoChange}
error={error}
/>
<LoginModal
visible={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
</ThemedView>
<PageLayout backgroundColor="#050505">
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
<StatusBarSpacer />
<Header />
<CategoryTabs categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
<FeatureCarousel items={featureItems} onPress={handleFeaturePress} />
<SectionHeader title="WAN 2.5 COMMUNITY" />
<CommunityGrid items={communityItems} onPressAction={handleGeneratePress} />
<View style={styles.bottomSpacer} />
</ScrollView>
</PageLayout>
);
}
const styles = StyleSheet.create({
container: {
scroll: {
flex: 1,
},
welcomeContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
contentContainer: {
paddingTop: 16,
paddingBottom: 48,
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: 'rgba(0, 122, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
welcomeTitle: {
fontSize: 28,
fontWeight: '700',
marginBottom: 12,
textAlign: 'center',
},
welcomeSubtitle: {
fontSize: 16,
textAlign: 'center',
opacity: 0.7,
marginBottom: 48,
lineHeight: 24,
},
loginButton: {
width: '100%',
height: 56,
borderRadius: 16,
overflow: 'hidden',
elevation: 8,
shadowColor: '#007AFF',
shadowOffset: { width: 0, height: 4 },
shadowRadius: 12,
shadowOpacity: 0.3,
},
loginButtonGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
loginButtonIcon: {
marginRight: 8,
},
loginButtonText: {
color: '#fff',
fontSize: 17,
fontWeight: '700',
},
guestButton: {
width: '100%',
height: 56,
borderRadius: 16,
borderWidth: 1.5,
borderColor: 'rgba(0, 122, 255, 0.3)',
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
},
guestButtonText: {
color: '#007AFF',
fontSize: 16,
fontWeight: '600',
},
headerBanner: {
position: 'absolute',
top: 16,
left: '10%',
right: '10%',
zIndex: 1000,
borderRadius: 20,
shadowColor: '#ff6b6b',
shadowOffset: { width: 0, height: 4 },
shadowRadius: 12,
elevation: 8,
overflow: 'hidden',
},
gradientBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 4,
paddingHorizontal: 16,
position: 'relative',
},
bannerText: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
textAlign: 'center',
},
couponText: {
fontWeight: '800',
fontSize: 14,
color: '#fff',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 6,
marginLeft: 6,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.6)',
borderStyle: 'dashed',
},
giftIcon: {
position: 'absolute',
right: 8,
fontSize: 16,
bottomSpacer: {
height: 32,
},
});

View File

@@ -48,7 +48,6 @@ const TABS: { key: TabKey; label: string }[] = [
const BILLING_OPTIONS: { key: BillingMode; label: string }[] = [
{ key: 'monthly', label: '月付' },
{ key: 'lifetime', label: '终身' },
];
const palettes: Record<'dark' | 'light', Palette> = {

View File

@@ -1,10 +1,9 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { AuthProvider } from '@/components/auth/auth-provider';
import { useColorScheme } from '@/hooks/use-color-scheme';
export const unstable_settings = {
anchor: '(tabs)',
@@ -17,11 +16,9 @@ export default function RootLayout() {
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<AuthProvider>
<Stack>
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</AuthProvider>
</ThemeProvider>
);

621
app/edit/[id].tsx Normal file
View File

@@ -0,0 +1,621 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { Copy, Download, Eraser, Image as ImageIcon, Redo2, RotateCcw, Undo2, Video } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import Button from '@/components/Button';
import CommonHeader from '@/components/CommonHeader';
interface EditableResult {
id: string;
originalContent: string;
currentContent: string;
type: 'text' | 'image' | 'video';
hasChanges: boolean;
lastModified: string;
history: string[];
historyIndex: number;
metadata?: {
imageUri?: string;
videoUri?: string;
duration?: number;
filters?: {
brightness: number;
contrast: number;
saturation: number;
};
crop?: {
x: number;
y: number;
width: number;
height: number;
};
};
}
const STORAGE_KEYS = {
RESULTS: 'editable_results',
VERSIONS: 'result_versions',
};
export default function EditResultScreen() {
const { id } = useLocalSearchParams();
const router = useRouter();
const [result, setResult] = useState<EditableResult | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [imageEditorVisible, setImageEditorVisible] = useState(false);
const [videoEditorVisible, setVideoEditorVisible] = useState(false);
const loadResult = useCallback(async () => {
try {
const storedResults = await AsyncStorage.getItem(STORAGE_KEYS.RESULTS);
if (storedResults) {
const results = JSON.parse(storedResults);
const foundResult = results.find((r: EditableResult) => r.id === id);
if (foundResult) {
setResult(foundResult);
} else {
createNewEditableResult();
}
} else {
createNewEditableResult();
}
} catch (error) {
Alert.alert('错误', '加载数据失败');
console.error('加载结果失败:', error);
} finally {
setLoading(false);
}
}, [id]);
const createNewEditableResult = useCallback(() => {
const newResult: EditableResult = {
id: String(id),
originalContent: '这是一段示例文本,您可以在这里编辑内容。',
currentContent: '这是一段示例文本,您可以在这里编辑内容。',
type: 'text',
hasChanges: false,
lastModified: new Date().toISOString(),
history: ['这是一段示例文本,您可以在这里编辑内容。'],
historyIndex: 0,
};
setResult(newResult);
}, [id]);
useFocusEffect(
useCallback(() => {
loadResult();
}, [loadResult])
);
const updateContent = useCallback((newContent: string) => {
if (!result) return;
const history = result.history || [];
const currentIndex = result.historyIndex;
if (currentIndex < history.length - 1) {
history.splice(currentIndex + 1);
}
history.push(result.currentContent);
setResult({
...result,
currentContent: newContent,
history,
historyIndex: Math.min(history.length - 1, 49),
hasChanges: newContent !== result.originalContent,
lastModified: new Date().toISOString(),
});
}, [result]);
const undo = useCallback(() => {
if (!result || result.historyIndex <= 0) return;
const newIndex = result.historyIndex - 1;
setResult({
...result,
currentContent: result.history[newIndex],
historyIndex: newIndex,
});
}, [result]);
const redo = useCallback(() => {
if (!result || result.historyIndex >= (result.history?.length || 0) - 1) return;
const newIndex = result.historyIndex + 1;
setResult({
...result,
currentContent: result.history[newIndex],
historyIndex: newIndex,
});
}, [result]);
const copyToClipboard = useCallback(() => {
if (!result?.currentContent) return;
Alert.alert('已复制', '内容已复制到剪贴板');
}, [result?.currentContent]);
const clearFormat = useCallback(() => {
Alert.alert('清除格式', '确定要清除所有格式吗?', [
{ text: '取消', style: 'cancel' },
{ text: '确定', onPress: () => updateContent(result?.currentContent || '') },
]);
}, [updateContent, result?.currentContent]);
const exportAsImage = useCallback(async () => {
Alert.alert('导出成功', '已保存到相册');
}, []);
const saveChanges = useCallback(async (saveAsNew: boolean = false) => {
if (!result) return;
setSaving(true);
try {
const storedResults = await AsyncStorage.getItem(STORAGE_KEYS.RESULTS);
const results: EditableResult[] = storedResults ? JSON.parse(storedResults) : [];
if (saveAsNew) {
const newResult: EditableResult = {
...result,
id: `${Date.now()}`,
originalContent: result.currentContent,
history: [result.currentContent],
historyIndex: 0,
hasChanges: false,
lastModified: new Date().toISOString(),
};
results.push(newResult);
await AsyncStorage.setItem(STORAGE_KEYS.RESULTS, JSON.stringify(results));
Alert.alert('保存成功', '已另存为新版本', [
{ text: '确定', onPress: () => router.back() },
]);
} else {
const updatedResults = results.map(r =>
r.id === result.id
? {
...result,
originalContent: result.currentContent,
history: [result.currentContent],
historyIndex: 0,
hasChanges: false,
}
: r
);
await AsyncStorage.setItem(STORAGE_KEYS.RESULTS, JSON.stringify(updatedResults));
Alert.alert('保存成功', '修改已保存', [
{ text: '确定', onPress: () => router.back() },
]);
}
} catch (error) {
Alert.alert('错误', '保存失败');
console.error('保存失败:', error);
} finally {
setSaving(false);
}
}, [result]);
const handleCancel = useCallback(() => {
if (result?.hasChanges) {
Alert.alert(
'未保存的更改',
'您有未保存的更改,确定要离开吗?',
[
{ text: '继续编辑', style: 'cancel' },
{
text: '离开',
style: 'destructive',
onPress: () => router.back(),
},
]
);
} else {
router.back();
}
}, [result?.hasChanges]);
const handleBackPress = useCallback(() => {
handleCancel();
}, [handleCancel]);
if (loading || !result) {
return (
<View style={styles.container}>
<Text style={styles.loadingText}>...</Text>
</View>
);
}
const canUndo = result.historyIndex > 0;
const canRedo = result.historyIndex < (result.history?.length || 0) - 1;
const wordCount = result.currentContent.length;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 88 : 0}
>
<CommonHeader
title="编辑结果"
onBack={handleBackPress}
rightContent={
<Button
title="保存"
onPress={() => saveChanges(false)}
variant="primary"
size="small"
disabled={!result.hasChanges || saving}
loading={saving}
style={styles.headerSaveButton}
/>
}
/>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.toolbar}>
<TouchableOpacity
style={[styles.toolButton, !canUndo && styles.toolButtonDisabled]}
onPress={undo}
disabled={!canUndo}
>
<Undo2 size={20} color={canUndo ? '#007AFF' : '#CCCCCC'} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.toolButton, !canRedo && styles.toolButtonDisabled]}
onPress={redo}
disabled={!canRedo}
>
<Redo2 size={20} color={canRedo ? '#007AFF' : '#CCCCCC'} />
</TouchableOpacity>
<TouchableOpacity style={styles.toolButton} onPress={copyToClipboard}>
<Copy size={20} color="#007AFF" />
</TouchableOpacity>
<TouchableOpacity style={styles.toolButton} onPress={clearFormat}>
<Eraser size={20} color="#007AFF" />
</TouchableOpacity>
<TouchableOpacity style={styles.toolButton} onPress={exportAsImage}>
<Download size={20} color="#007AFF" />
</TouchableOpacity>
</View>
<View style={styles.editorSection}>
<View style={styles.typeIndicator}>
{result.type === 'text' && <Text style={styles.typeText}></Text>}
{result.type === 'image' && <Text style={styles.typeText}></Text>}
{result.type === 'video' && <Text style={styles.typeText}></Text>}
</View>
{result.type === 'text' ? (
<View style={styles.textEditorContainer}>
<TextInput
style={styles.textInput}
value={result.currentContent}
onChangeText={updateContent}
multiline
placeholder="在此编辑内容..."
textAlignVertical="top"
maxLength={10000}
/>
<View style={styles.wordCount}>
<Text style={styles.wordCountText}>
{wordCount}/10000
</Text>
</View>
</View>
) : result.type === 'image' ? (
<View style={styles.imageEditorContainer}>
<View style={styles.imagePreview}>
<ImageIcon size={48} color="#CCCCCC" />
<Text style={styles.imageEditorText}></Text>
<TouchableOpacity
style={styles.imageEditButton}
onPress={() => setImageEditorVisible(true)}
>
<Text style={styles.imageEditButtonText}></Text>
</TouchableOpacity>
</View>
<View style={styles.imageTools}>
<TouchableOpacity style={styles.imageToolButton}>
<RotateCcw size={20} color="#007AFF" />
<Text style={styles.imageToolText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.imageToolButton}>
<ImageIcon size={20} color="#007AFF" />
<Text style={styles.imageToolText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.imageToolButton}>
<Download size={20} color="#007AFF" />
<Text style={styles.imageToolText}></Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={styles.videoEditorContainer}>
<View style={styles.videoPreview}>
<Video size={48} color="#CCCCCC" />
<Text style={styles.videoEditorText}></Text>
<TouchableOpacity
style={styles.videoEditButton}
onPress={() => setVideoEditorVisible(true)}
>
<Text style={styles.videoEditButtonText}></Text>
</TouchableOpacity>
</View>
<View style={styles.videoTools}>
<TouchableOpacity style={styles.videoToolButton}>
<ImageIcon size={20} color="#007AFF" />
<Text style={styles.videoToolText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.videoToolButton}>
<Eraser size={20} color="#007AFF" />
<Text style={styles.videoToolText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.videoToolButton}>
<Text style={[styles.videoToolText, { color: '#007AFF' }]}></Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
{result.hasChanges && (
<View style={styles.changesIndicator}>
<Text style={styles.changesText}> </Text>
</View>
)}
</ScrollView>
<View style={styles.bottomActions}>
<Button
title="保存修改"
onPress={() => saveChanges(false)}
variant="primary"
size="large"
fullWidth
disabled={!result.hasChanges || saving}
loading={saving}
style={styles.primaryButton}
/>
<Button
title="另存为新版本"
onPress={() => saveChanges(true)}
variant="outline"
size="large"
fullWidth
disabled={saving}
style={styles.secondaryButton}
/>
<Button
title="取消"
onPress={handleCancel}
variant="text"
size="medium"
fullWidth
style={styles.cancelButton}
/>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
loadingText: {
textAlign: 'center',
marginTop: 100,
fontSize: 16,
color: '#666666',
},
headerSaveButton: {
width: 80,
height: 36,
},
content: {
flex: 1,
},
toolbar: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#EEEEEE',
gap: 12,
},
toolButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F0F0F0',
justifyContent: 'center',
alignItems: 'center',
},
toolButtonDisabled: {
opacity: 0.4,
},
editorSection: {
backgroundColor: '#FFFFFF',
margin: 16,
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
typeIndicator: {
marginBottom: 12,
},
typeText: {
fontSize: 14,
color: '#007AFF',
fontWeight: '600',
},
textEditorContainer: {
position: 'relative',
},
textInput: {
minHeight: 300,
padding: 16,
fontSize: 16,
lineHeight: 24,
color: '#333333',
borderRadius: 8,
borderWidth: 1,
borderColor: '#EEEEEE',
backgroundColor: '#FAFAFA',
},
wordCount: {
position: 'absolute',
bottom: 8,
right: 16,
paddingHorizontal: 8,
paddingVertical: 4,
backgroundColor: '#F5F5F5',
borderRadius: 4,
},
wordCountText: {
fontSize: 12,
color: '#999999',
},
imageEditorContainer: {
gap: 16,
},
imagePreview: {
height: 250,
backgroundColor: '#F5F5F5',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
imageEditorText: {
fontSize: 14,
color: '#666666',
},
imageEditButton: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#007AFF',
borderRadius: 6,
},
imageEditButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
imageTools: {
flexDirection: 'row',
gap: 12,
},
imageToolButton: {
flex: 1,
paddingVertical: 12,
backgroundColor: '#F0F0F0',
borderRadius: 8,
alignItems: 'center',
gap: 6,
},
imageToolText: {
fontSize: 12,
color: '#007AFF',
fontWeight: '500',
},
videoEditorContainer: {
gap: 16,
},
videoPreview: {
height: 250,
backgroundColor: '#F5F5F5',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
videoEditorText: {
fontSize: 14,
color: '#666666',
},
videoEditButton: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#007AFF',
borderRadius: 6,
},
videoEditButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
videoTools: {
flexDirection: 'row',
gap: 12,
},
videoToolButton: {
flex: 1,
paddingVertical: 12,
backgroundColor: '#F0F0F0',
borderRadius: 8,
alignItems: 'center',
gap: 6,
},
videoToolText: {
fontSize: 12,
color: '#007AFF',
fontWeight: '500',
},
changesIndicator: {
margin: 16,
padding: 12,
backgroundColor: '#FFF3CD',
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#FFC107',
},
changesText: {
fontSize: 14,
color: '#856404',
fontWeight: '500',
},
bottomActions: {
padding: 16,
backgroundColor: '#FFFFFF',
borderTopWidth: 1,
borderTopColor: '#EEEEEE',
gap: 12,
},
primaryButton: {
marginBottom: 4,
},
secondaryButton: {
marginBottom: 4,
},
cancelButton: {},
});

387
app/exchange.tsx Normal file
View File

@@ -0,0 +1,387 @@
import Ionicons from '@expo/vector-icons/Ionicons';
import { useRouter } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type PurchaseTab = 'subscription' | 'pack';
type PointsBundle = {
id: string;
points: number;
price: number;
};
const screenPalette = {
background: '#050505',
surface: '#101010',
surfaceRaised: '#1A1B1E',
primaryText: '#F5F5F5',
secondaryText: '#7F7F7F',
mutedText: '#4C4C4C',
accent: '#FEB840',
energyHalo: 'rgba(254, 184, 64, 0.22)',
divider: '#1C1C1C',
button: '#D1FE17',
buttonText: '#101010',
};
const CURRENT_POINTS = 60;
const SUBSCRIPTION_TAGLINE = 'No active subscription plans';
const TABS: { key: PurchaseTab; label: string }[] = [
{ key: 'subscription', label: 'Subscription' },
{ key: 'pack', label: 'points pack' },
];
const POINT_BUNDLES: PointsBundle[] = [
{ id: 'bundle-500', points: 500, price: 35 },
{ id: 'bundle-2000', points: 2000, price: 140 },
{ id: 'bundle-5000', points: 5000, price: 350 },
{ id: 'bundle-10000', points: 10000, price: 700 },
];
export default function PointsExchangeScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<PurchaseTab>('pack');
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
const visibleBundles = useMemo(
() => (activeTab === 'pack' ? POINT_BUNDLES : []),
[activeTab],
);
const changeTab = useCallback((nextTab: PurchaseTab) => {
setActiveTab(nextTab);
if (nextTab !== 'pack') {
setSelectedBundleId(null);
}
}, []);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const openPointsDetails = useCallback(() => {
router.push('/points-details');
}, [router]);
const handleBundleSelect = useCallback((bundleId: string) => {
setSelectedBundleId(bundleId);
}, []);
const handlePurchase = useCallback(() => {
const bundle = POINT_BUNDLES.find(item => item.id === selectedBundleId);
if (!bundle) {
Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.');
return;
}
Alert.alert(
'Confirm purchase',
`You are purchasing ${bundle.points} points for ¥ ${bundle.price}.`,
);
}, [selectedBundleId]);
return (
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
<View style={styles.headerRow}>
<TouchableOpacity
style={styles.iconButton}
accessibilityRole="button"
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={22} color={screenPalette.primaryText} />
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryPill}
accessibilityRole="button"
onPress={openPointsDetails}
activeOpacity={0.85}
>
<Text style={styles.secondaryPillLabel}>Points Details</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={[
styles.contentContainer,
{ paddingBottom: Math.max(insets.bottom + 96, 160) },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>{CURRENT_POINTS}</Text>
<Text style={styles.balanceSubtitle}>{SUBSCRIPTION_TAGLINE}</Text>
</View>
<View style={styles.tabBar}>
{TABS.map(tab => {
const isActive = tab.key === activeTab;
return (
<TouchableOpacity
key={tab.key}
style={styles.tabButton}
onPress={() => changeTab(tab.key)}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
activeOpacity={0.8}
>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
</Text>
<View
style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]}
/>
</TouchableOpacity>
);
})}
</View>
<View style={styles.tabDivider} />
{activeTab === 'pack' ? (
<View style={styles.packGrid}>
{visibleBundles.map(bundle => {
const isSelected = bundle.id === selectedBundleId;
return (
<TouchableOpacity
key={bundle.id}
style={[
styles.packCard,
isSelected && styles.packCardSelected,
]}
onPress={() => handleBundleSelect(bundle.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
activeOpacity={0.88}
>
<View style={styles.packHeader}>
<Ionicons name="flash" size={18} color={screenPalette.accent} />
<Text style={styles.packPoints}>{bundle.points}</Text>
</View>
<Text style={styles.packPrice}>¥ {bundle.price}</Text>
</TouchableOpacity>
);
})}
</View>
) : (
<View style={styles.subscriptionEmpty}>
<Text style={styles.emptyTitle}>Subscription</Text>
<Text style={styles.emptySubtitle}>
No subscription tiers are available at the moment.
</Text>
</View>
)}
</ScrollView>
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
<TouchableOpacity
style={styles.primaryButton}
onPress={handlePurchase}
activeOpacity={0.85}
>
<Text style={styles.primaryButtonLabel}>Purchase points</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: screenPalette.background,
},
headerRow: {
paddingHorizontal: 24,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
secondaryPill: {
paddingHorizontal: 16,
height: 34,
borderRadius: 17,
backgroundColor: screenPalette.surface,
borderWidth: StyleSheet.hairlineWidth,
borderColor: screenPalette.divider,
justifyContent: 'center',
alignItems: 'center',
},
secondaryPillLabel: {
fontSize: 14,
fontWeight: '600',
color: screenPalette.primaryText,
letterSpacing: 0.2,
},
content: {
flex: 1,
},
contentContainer: {
paddingHorizontal: 24,
},
balanceCluster: {
alignItems: 'center',
marginTop: 32,
marginBottom: 36,
gap: 10,
},
energyOrb: {
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: screenPalette.energyHalo,
alignItems: 'center',
justifyContent: 'center',
shadowColor: screenPalette.accent,
shadowOpacity: 0.28,
shadowRadius: 14,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
balanceValue: {
fontSize: 44,
fontWeight: '700',
color: screenPalette.primaryText,
letterSpacing: 0.5,
fontVariant: ['tabular-nums'],
},
balanceSubtitle: {
fontSize: 13,
color: screenPalette.secondaryText,
letterSpacing: 0.2,
},
tabBar: {
flexDirection: 'row',
gap: 32,
paddingHorizontal: 4,
},
tabButton: {
flex: 1,
alignItems: 'center',
paddingBottom: 12,
},
tabLabel: {
fontSize: 14,
color: screenPalette.mutedText,
fontWeight: '500',
letterSpacing: 0.2,
textTransform: 'capitalize',
},
tabLabelActive: {
color: screenPalette.primaryText,
},
tabIndicator: {
marginTop: 10,
height: 2,
width: '100%',
backgroundColor: 'transparent',
borderRadius: 1,
},
tabIndicatorActive: {
backgroundColor: screenPalette.accent,
},
tabDivider: {
marginTop: 12,
height: StyleSheet.hairlineWidth,
backgroundColor: screenPalette.divider,
},
packGrid: {
marginTop: 28,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 18,
},
packCard: {
width: '47%',
backgroundColor: screenPalette.surfaceRaised,
borderRadius: 24,
paddingHorizontal: 18,
paddingVertical: 24,
justifyContent: 'space-between',
borderWidth: 1,
borderColor: screenPalette.surfaceRaised,
},
packCardSelected: {
borderColor: screenPalette.accent,
},
packHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
packPoints: {
fontSize: 18,
fontWeight: '700',
color: screenPalette.primaryText,
fontVariant: ['tabular-nums'],
},
packPrice: {
marginTop: 18,
fontSize: 14,
color: screenPalette.secondaryText,
},
subscriptionEmpty: {
marginTop: 64,
backgroundColor: screenPalette.surface,
borderRadius: 22,
paddingVertical: 40,
paddingHorizontal: 32,
alignItems: 'center',
gap: 12,
},
emptyTitle: {
fontSize: 16,
fontWeight: '600',
color: screenPalette.primaryText,
},
emptySubtitle: {
fontSize: 13,
color: screenPalette.secondaryText,
textAlign: 'center',
lineHeight: 20,
},
bottomBar: {
paddingHorizontal: 24,
},
primaryButton: {
height: 54,
borderRadius: 27,
backgroundColor: screenPalette.button,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonLabel: {
fontSize: 16,
fontWeight: '700',
color: screenPalette.buttonText,
letterSpacing: 0.3,
},
});

View File

@@ -1,6 +1,7 @@
import Ionicons from '@expo/vector-icons/Ionicons';
import { useRouter } from 'expo-router';
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import type { ListRenderItem } from 'react-native';
import {
FlatList,
StatusBar,
@@ -25,14 +26,15 @@ type FilterKey = 'all' | LedgerKind;
const screenPalette = {
background: '#080808',
surface: '#111111',
surface: '#101010',
surfaceActive: '#1C1C1C',
divider: '#1A1A1A',
surfaceOutline: '#1D1D1D',
divider: '#161616',
primaryText: '#F5F5F5',
secondaryText: '#6C6C6C',
accent: '#FEB840',
accentHalo: 'rgba(254, 184, 64, 0.18)',
negative: '#B3B3B3',
accentHalo: 'rgba(254, 184, 64, 0.22)',
negative: '#8F8F8F',
};
const FILTERS: { key: FilterKey; label: string }[] = [
@@ -106,25 +108,48 @@ export default function PointsDetailsScreen() {
return LEDGER.filter(entry => entry.kind === activeFilter);
}, [activeFilter]);
const listContentStyle = useMemo(
() => ({
paddingBottom: Math.max(insets.bottom + 36, 72),
paddingTop: 4,
}),
[insets.bottom],
);
const keyExtractor = useCallback((entry: LedgerEntry) => entry.id, []);
const renderSeparator = useCallback(() => <View style={styles.listDivider} />, []);
const renderLedgerEntry: ListRenderItem<LedgerEntry> = useCallback(({ item }) => {
const amount = Math.abs(item.amount);
const isPositive = item.amount > 0;
return (
<View style={styles.recordRow}>
<View>
<Text style={styles.recordTitle}>{item.title}</Text>
<Text style={styles.recordTimestamp}>{item.happenedAt}</Text>
</View>
<Text
style={[
styles.recordValue,
isPositive ? styles.valuePositive : styles.valueNegative,
]}
>
{isPositive ? `+ ${amount}` : `- ${amount}`}
</Text>
</View>
);
}, []);
return (
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
<StatusBar barStyle="light-content" />
<View style={styles.headerRow}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => router.back()}
style={styles.iconButton}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<Ionicons name="chevron-back" size={24} color={screenPalette.primaryText} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Points Details</Text>
<View style={styles.iconButton} />
</View>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={20} color={screenPalette.accent} />
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>60</Text>
</View>
@@ -150,32 +175,12 @@ export default function PointsDetailsScreen() {
<FlatList
data={filteredEntries}
keyExtractor={item => item.id}
keyExtractor={keyExtractor}
style={styles.list}
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom + 24, 64) }}
contentContainerStyle={listContentStyle}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={styles.listDivider} />}
renderItem={({ item }) => {
const amount = Math.abs(item.amount);
const isPositive = item.amount > 0;
return (
<View style={styles.recordRow}>
<View>
<Text style={styles.recordTitle}>{item.title}</Text>
<Text style={styles.recordTimestamp}>{item.happenedAt}</Text>
</View>
<Text
style={[
styles.recordValue,
isPositive ? styles.valuePositive : styles.valueNegative,
]}
>
{isPositive ? `+ ${amount}` : `- ${amount}`}
</Text>
</View>
);
}}
ItemSeparatorComponent={renderSeparator}
renderItem={renderLedgerEntry}
/>
</View>
);
@@ -185,98 +190,117 @@ const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: screenPalette.background,
paddingHorizontal: 20,
paddingHorizontal: 24,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 48,
},
iconButton: {
width: 44,
height: 44,
borderRadius: 22,
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 18,
fontSize: 17,
lineHeight: 24,
fontWeight: '600',
color: screenPalette.primaryText,
letterSpacing: 0.3,
letterSpacing: 0.2,
},
balanceCluster: {
alignItems: 'center',
marginTop: 28,
marginBottom: 32,
gap: 12,
marginTop: 32,
marginBottom: 28,
gap: 10,
},
energyOrb: {
width: 56,
height: 56,
borderRadius: 28,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: screenPalette.accentHalo,
alignItems: 'center',
justifyContent: 'center',
shadowColor: screenPalette.accent,
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
balanceValue: {
fontSize: 44,
fontSize: 46,
lineHeight: 52,
fontWeight: '700',
color: screenPalette.primaryText,
letterSpacing: 1,
letterSpacing: 0.5,
fontVariant: ['tabular-nums'],
},
segmentRail: {
flexDirection: 'row',
backgroundColor: screenPalette.surface,
padding: 6,
padding: 4,
borderRadius: 28,
borderWidth: StyleSheet.hairlineWidth,
borderColor: screenPalette.surfaceOutline,
gap: 4,
},
segmentChip: {
flex: 1,
borderRadius: 22,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
paddingVertical: 11,
},
segmentChipActive: {
backgroundColor: screenPalette.surfaceActive,
},
segmentLabel: {
fontSize: 14,
lineHeight: 20,
color: screenPalette.secondaryText,
fontWeight: '500',
letterSpacing: 0.2,
},
segmentLabelActive: {
color: screenPalette.primaryText,
},
list: {
flex: 1,
marginTop: 32,
marginTop: 28,
},
listDivider: {
height: 1,
height: StyleSheet.hairlineWidth,
backgroundColor: screenPalette.divider,
marginVertical: 6,
marginVertical: 0,
},
recordRow: {
flexDirection: 'row',
alignItems: 'flex-start',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
paddingVertical: 14,
},
recordTitle: {
fontSize: 16,
fontSize: 15,
lineHeight: 22,
fontWeight: '600',
color: screenPalette.primaryText,
},
recordTimestamp: {
marginTop: 4,
fontSize: 13,
marginTop: 6,
fontSize: 12,
lineHeight: 18,
color: screenPalette.secondaryText,
letterSpacing: 0.2,
},
recordValue: {
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
fontVariant: ['tabular-nums'],
},
valuePositive: {
color: screenPalette.accent,

View File

@@ -1,31 +1,310 @@
import React from 'react';
import { StyleSheet, ScrollView } from 'react-native';
import { router } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { X, Zap } from 'lucide-react';
import React, { useState } from 'react';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
const pointBundles = [
{ id: 'bundle-500', amount: 500, price: 35 },
{ id: 'bundle-2000', amount: 2000, price: 140 },
{ id: 'bundle-5000', amount: 5000, price: 350 },
{ id: 'bundle-10000', amount: 10000, price: 700 },
];
export default function RechargeScreen() {
const backgroundColor = useThemeColor({}, 'background');
const insets = useSafeAreaInsets();
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
const handleBundleSelection = (bundleId: string) => {
setSelectedBundleId(bundleId);
};
const handlePurchase = () => {
if (!selectedBundleId) {
Alert.alert(
'Choose a pack',
'Select the points pack that fits your journey before proceeding.'
);
return;
}
const selectedBundle = pointBundles.find(bundle => bundle.id === selectedBundleId);
if (!selectedBundle) {
Alert.alert('Unavailable pack', 'The chosen points pack is no longer available.');
return;
}
Alert.alert(
'Purchase in progress',
`Preparing ${selectedBundle.amount} points for ¥${selectedBundle.price}.`
);
};
return (
<ScrollView style={[styles.container, { backgroundColor }]}>
<ThemedView style={styles.content}>
<ThemedText type="title"></ThemedText>
<ThemedText>...</ThemedText>
</ThemedView>
</ScrollView>
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<StatusBar style="light" />
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<View style={styles.frame}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.headerRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close recharge"
onPress={() => router.back()}
style={styles.dismissButton}
>
<X color="#FFFFFF" size={22} strokeWidth={2.2} />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="View points details"
onPress={() => router.push('/points')}
style={styles.pointsDetails}
>
<Text style={styles.pointsDetailsText}>Points Details</Text>
</Pressable>
</View>
<View style={styles.balanceBlock}>
<View style={styles.balanceRow}>
<Zap color="#FFCE38" size={30} strokeWidth={2.2} />
<Text style={styles.balanceValue}>60</Text>
</View>
<Text style={styles.balanceCaption}>No active subscription plans</Text>
</View>
<View style={styles.tabStrip}>
<Text style={styles.tabInactive}>Subscription</Text>
<View style={styles.tabActive}>
<Text style={styles.tabActiveText}>points pack</Text>
<View style={styles.tabIndicator} />
</View>
</View>
<View style={styles.bundleGrid}>
{pointBundles.map(bundle => (
<Pressable
key={bundle.id}
accessibilityRole="button"
accessibilityLabel={`Purchase ${bundle.amount} points for ¥${bundle.price}`}
onPress={() => handleBundleSelection(bundle.id)}
style={({ pressed }) => [
styles.bundleCard,
selectedBundleId === bundle.id && styles.bundleCardActive,
pressed && styles.bundleCardPressed,
]}
>
<View style={styles.bundleHeader}>
<Zap color="#FFCE38" size={22} strokeWidth={2.2} />
<Text
style={[
styles.bundleAmount,
selectedBundleId === bundle.id && styles.bundleAmountActive,
]}
>
{bundle.amount}
</Text>
</View>
<Text
style={[
styles.bundlePrice,
selectedBundleId === bundle.id && styles.bundlePriceActive,
]}
>
¥ {bundle.price}
</Text>
</Pressable>
))}
</View>
</ScrollView>
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Purchase points"
onPress={handlePurchase}
style={({ pressed }) => [
styles.purchaseButton,
pressed && styles.purchaseButtonPressed,
]}
>
<Text style={styles.purchaseButtonText}>Purchase points</Text>
</Pressable>
</View>
</View>
</SafeAreaView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
canvas: {
flex: 1,
},
content: {
safeArea: {
flex: 1,
justifyContent: 'center',
},
frame: {
flex: 1,
paddingHorizontal: 20,
},
scrollContent: {
paddingBottom: 32,
},
headerRow: {
marginTop: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
},
dismissButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
pointsDetails: {
backgroundColor: '#181818',
borderRadius: 20,
borderWidth: 1,
borderColor: '#2C2C2C',
paddingHorizontal: 16,
paddingVertical: 8,
},
pointsDetailsText: {
color: '#F1F1F1',
fontSize: 14,
fontWeight: '500',
letterSpacing: 0.3,
},
balanceBlock: {
marginTop: 32,
alignItems: 'center',
},
balanceRow: {
flexDirection: 'row',
alignItems: 'center',
},
balanceValue: {
marginLeft: 12,
fontSize: 48,
fontWeight: '700',
color: '#FFFFFF',
letterSpacing: 0.5,
},
balanceCaption: {
marginTop: 12,
fontSize: 16,
color: '#8E8E8E',
letterSpacing: 0.2,
},
tabStrip: {
marginTop: 40,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-end',
},
tabInactive: {
marginRight: 32,
fontSize: 16,
color: '#555555',
fontWeight: '500',
textTransform: 'capitalize',
},
tabActive: {
alignItems: 'center',
},
tabActiveText: {
fontSize: 16,
color: '#FFFFFF',
fontWeight: '600',
textTransform: 'capitalize',
},
tabIndicator: {
marginTop: 10,
height: 2,
width: 70,
backgroundColor: '#FFD84E',
borderRadius: 2,
},
bundleGrid: {
marginTop: 32,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
bundleCard: {
width: '47%',
backgroundColor: '#111111',
borderRadius: 24,
paddingVertical: 24,
paddingHorizontal: 18,
borderWidth: 1,
borderColor: '#1F1F1F',
justifyContent: 'space-between',
marginBottom: 20,
},
bundleCardActive: {
borderColor: '#FFD84E',
},
bundleCardPressed: {
transform: [{ scale: 0.98 }],
},
bundleHeader: {
flexDirection: 'row',
alignItems: 'center',
},
bundleAmount: {
marginLeft: 12,
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
letterSpacing: 0.3,
},
bundleAmountActive: {
color: '#FFD84E',
},
bundlePrice: {
marginTop: 24,
fontSize: 18,
fontWeight: '500',
color: '#A4A4A4',
letterSpacing: 0.2,
},
bundlePriceActive: {
color: '#FFD84E',
},
footer: {
paddingTop: 16,
},
purchaseButton: {
height: 56,
borderRadius: 28,
backgroundColor: '#CFFF21',
alignItems: 'center',
justifyContent: 'center',
},
purchaseButtonPressed: {
opacity: 0.9,
},
purchaseButtonText: {
fontSize: 18,
fontWeight: '700',
color: '#101010',
letterSpacing: 0.4,
},
});

542
app/result.tsx Normal file
View File

@@ -0,0 +1,542 @@
import { ThemedView } from '@/components/themed-view';
import { ResizeMode, Video } from 'expo-av';
import * as MediaLibrary from 'expo-media-library';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Copy,
Download,
Maximize2,
Plus,
X,
ArrowLeft,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import {
Alert,
Dimensions,
Image,
ImageStyle,
Modal,
ScrollView,
StyleSheet,
StyleProp,
Text,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native';
const { width: screenWidth } = Dimensions.get('window');
const HERO_HEIGHT = screenWidth * 0.58;
const DETAIL_HEIGHT = screenWidth * 0.95;
interface ResultWithTemplate {
id: string;
userId: string;
templateId: string;
type: 'TEXT' | 'IMAGE' | 'VIDEO';
resultUrl: string[];
status: 'pending' | 'running' | 'completed' | 'failed';
creditsCost?: number;
creditsTransactionId?: string;
createdAt: string;
updatedAt: string;
templateName?: string;
templateThumbnail?: string;
templateDescription?: string;
}
export default function ResultPage() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const [result, setResult] = useState<ResultWithTemplate | null>(null);
const [fullscreenVisible, setFullscreenVisible] = useState(false);
const [fullscreenContent, setFullscreenContent] = useState<{
type: 'image' | 'video';
url: string;
} | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadResult();
}, [id]);
const loadResult = async () => {
try {
const typeParam = typeof id === 'string' ? id.split('_')[1] : 'text';
const resultId = typeof id === 'string' ? id : 'res1_text';
const getResultByType = () => {
switch (typeParam) {
case 'image':
return {
id: resultId,
userId: 'user1',
templateId: 'tpl2',
type: 'IMAGE' as const,
resultUrl: ['https://picsum.photos/800/600?random=1'],
status: 'completed' as const,
creditsCost: 20,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
templateName: 'AI图片生成',
templateThumbnail: 'https://picsum.photos/60/60?random=2',
templateDescription: '使用AI生成高质量图片',
};
case 'video':
return {
id: resultId,
userId: 'user1',
templateId: 'tpl3',
type: 'VIDEO' as const,
resultUrl: ['https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'],
status: 'completed' as const,
creditsCost: 50,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
templateName: 'AI视频生成',
templateThumbnail: 'https://picsum.photos/60/60?random=3',
templateDescription: '基于文本生成视频内容',
};
default:
return {
id: resultId,
userId: 'user1',
templateId: 'tpl1',
type: 'TEXT' as const,
resultUrl: [
'这是一段AI生成的营销文案专门为您的产品精心打造。它突出了产品的核心优势采用了吸引人的语言风格能够有效提升用户的购买欲望和品牌认知度。通过深入分析目标受众的需求和痛点我们精心设计了这份文案旨在最大化品牌影响力和转化效果。',
],
status: 'completed' as const,
creditsCost: 10,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
templateName: 'AI文案生成',
templateThumbnail: 'https://picsum.photos/60/60?random=1',
templateDescription: '基于关键词生成营销文案',
};
}
};
setResult(getResultByType());
} catch (error) {
Alert.alert('错误', '无法加载结果详情');
}
};
const handleRerun = () => {
router.push(`/templates/${result?.templateId}/form`);
};
const handleSaveToGallery = async () => {
if (!result || result.type === 'TEXT') return;
try {
setSaving(true);
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
return;
}
Alert.alert('保存成功', `${result.type === 'IMAGE' ? '图片' : '视频'}已保存到相册`);
} catch (error) {
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
} finally {
setSaving(false);
}
};
const handleCopyText = async () => {
if (!result || result.type !== 'TEXT') return;
try {
await navigator.clipboard?.writeText?.(result.resultUrl[0]);
Alert.alert('复制成功', '文本已复制到剪贴板');
} catch (error) {
Alert.alert('复制失败', '无法复制文本');
}
};
const openFullscreen = (type: 'image' | 'video', url: string) => {
setFullscreenContent({ type, url });
setFullscreenVisible(true);
};
const handleDownloadPress = () => {
if (!result) return;
if (result.type === 'TEXT') {
handleCopyText();
return;
}
handleSaveToGallery();
};
const renderVisualMedia = (url: string, style: StyleProp<ImageStyle> | StyleProp<ViewStyle>) => {
if (!url) return null;
if (result?.type === 'VIDEO') {
return (
<Video
source={{ uri: url }}
style={style}
resizeMode={ResizeMode.COVER}
shouldPlay={false}
isMuted
/>
);
}
return (
<Image
source={{ uri: url }}
style={style}
resizeMode="cover"
/>
);
};
if (!result) {
return (
<ThemedView style={styles.screen}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
</ThemedView>
);
}
const isTextResult = result.type === 'TEXT';
const primaryPreviewUrl = result.resultUrl[0] ?? '';
return (
<ThemedView style={styles.screen}>
<View style={[styles.safeGuard, { paddingTop: insets.top || 16 }]} />
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerRow}>
<TouchableOpacity
onPress={() => router.back()}
activeOpacity={0.7}
style={styles.backButton}
>
<ArrowLeft size={22} color="#F6F6F8" />
</TouchableOpacity>
</View>
<Text style={styles.title}>
INSEAR YOUR PRODUCT INTO AN ASMR VIDEO
</Text>
<Text style={styles.subtitle}>
ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.
</Text>
<View style={styles.heroCard}>
{isTextResult ? (
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
<Text style={styles.generatedText}>{primaryPreviewUrl}</Text>
</ScrollView>
) : (
<>
{renderVisualMedia(primaryPreviewUrl, styles.heroMedia)}
{result.templateThumbnail && (
<View style={styles.referencePreview}>
<Image
source={{ uri: result.templateThumbnail }}
style={styles.referenceImage}
/>
</View>
)}
</>
)}
</View>
{!isTextResult && (
<TouchableOpacity
activeOpacity={0.85}
style={styles.detailCard}
onPress={() => openFullscreen(result.type === 'VIDEO' ? 'video' : 'image', primaryPreviewUrl)}
>
<View style={styles.expandIcon}>
<Maximize2 size={18} color="#F5F5F7" />
</View>
<View style={styles.detailMediaWrapper}>
{renderVisualMedia(primaryPreviewUrl, styles.detailMedia)}
</View>
</TouchableOpacity>
)}
</ScrollView>
<Modal
visible={fullscreenVisible}
transparent={false}
animationType="fade"
onRequestClose={() => setFullscreenVisible(false)}
>
<View style={styles.fullscreenContainer}>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setFullscreenVisible(false)}
>
<X size={30} color="#FFFFFF" />
</TouchableOpacity>
{fullscreenContent && (
<View style={styles.fullscreenMedia}>
{fullscreenContent.type === 'image' ? (
<Image
source={{ uri: fullscreenContent.url }}
style={styles.fullscreenMediaContent}
resizeMode="contain"
/>
) : (
<Video
source={{ uri: fullscreenContent.url }}
style={styles.fullscreenMediaContent}
shouldPlay
useNativeControls
resizeMode={ResizeMode.CONTAIN}
/>
)}
</View>
)}
</View>
</Modal>
<View
style={[
styles.bottomBar,
{
paddingBottom: Math.max(insets.bottom, 20),
},
]}
>
<TouchableOpacity
activeOpacity={0.8}
style={styles.secondaryAction}
onPress={handleRerun}
>
<Plus size={20} color="#F6F6F8" />
<Text style={styles.secondaryText}>Regenerate</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.primaryAction,
saving && { opacity: 0.7 },
]}
onPress={handleDownloadPress}
disabled={saving}
>
{isTextResult ? (
<Copy size={20} color="#1C1C1E" />
) : (
<Download size={20} color="#1C1C1E" />
)}
<Text style={styles.primaryText}>
{isTextResult ? 'Copy Text' : 'Download'}
</Text>
</TouchableOpacity>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#09090B',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#09090B',
},
loadingText: {
color: '#F5F5F7',
fontSize: 16,
},
safeGuard: {
backgroundColor: 'transparent',
},
scrollContent: {
paddingHorizontal: 24,
paddingBottom: 160,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
marginBottom: 24,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(32, 32, 36, 0.6)',
},
title: {
color: '#F5F5F7',
fontSize: 22,
fontWeight: '700',
lineHeight: 30,
marginBottom: 12,
letterSpacing: 0.4,
},
subtitle: {
color: '#9A9AA2',
fontSize: 14,
lineHeight: 20,
},
heroCard: {
marginTop: 32,
borderRadius: 32,
backgroundColor: '#131317',
padding: 24,
position: 'relative',
overflow: 'hidden',
},
heroMedia: {
width: '100%',
height: HERO_HEIGHT,
borderRadius: 28,
},
textScroll: {
maxHeight: HERO_HEIGHT,
},
generatedText: {
color: '#F5F5F7',
fontSize: 16,
lineHeight: 24,
},
referencePreview: {
position: 'absolute',
bottom: 24,
left: 24,
width: 74,
height: 74,
borderRadius: 20,
padding: 4,
backgroundColor: '#050506',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
referenceImage: {
width: '100%',
height: '100%',
borderRadius: 16,
},
detailCard: {
marginTop: 28,
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
backgroundColor: '#101012',
padding: 20,
position: 'relative',
},
detailMediaWrapper: {
borderRadius: 24,
overflow: 'hidden',
},
detailMedia: {
width: '100%',
height: DETAIL_HEIGHT,
},
expandIcon: {
position: 'absolute',
right: 20,
top: 20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(24, 24, 28, 0.86)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
fullscreenContainer: {
flex: 1,
backgroundColor: '#000000',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
position: 'absolute',
top: 50,
right: 20,
zIndex: 1,
width: 50,
height: 50,
justifyContent: 'center',
alignItems: 'center',
},
fullscreenMedia: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
fullscreenMediaContent: {
width: '100%',
height: '100%',
},
bottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 24,
paddingTop: 16,
backgroundColor: 'rgba(6, 6, 7, 0.94)',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
secondaryAction: {
flex: 1,
height: 60,
borderRadius: 30,
backgroundColor: '#151519',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
marginRight: 12,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
secondaryText: {
color: '#F5F5F7',
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
},
primaryAction: {
flex: 1,
height: 60,
borderRadius: 30,
backgroundColor: '#D9FF3F',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
primaryText: {
color: '#1C1C1E',
fontSize: 16,
fontWeight: '700',
marginLeft: 10,
},
});

217
app/settings/index.tsx Normal file
View File

@@ -0,0 +1,217 @@
import Ionicons from '@expo/vector-icons/Ionicons';
import { StatusBar } from 'expo-status-bar';
import { useRouter } from 'expo-router';
import React, { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { authClient } from '@/lib/auth/client';
type Palette = {
canvas: string;
card: string;
textPrimary: string;
textSecondary: string;
icon: string;
stroke: string;
buttonBackground: string;
buttonText: string;
};
type SettingDestination = {
key: string;
label: string;
route?: string;
};
const palettes: Record<'dark' | 'light', Palette> = {
dark: {
canvas: '#050505',
card: '#141417',
textPrimary: '#F5F5F7',
textSecondary: '#8E8E93',
icon: '#F5F5F7',
stroke: '#232327',
buttonBackground: '#FFFFFF',
buttonText: '#050505',
},
light: {
canvas: '#F7F7F9',
card: '#FFFFFF',
textPrimary: '#131318',
textSecondary: '#5C5C67',
icon: '#131318',
stroke: '#E4E4EA',
buttonBackground: '#131318',
buttonText: '#FFFFFF',
},
};
const destinations: SettingDestination[] = [
{ key: 'change-password', label: 'Change Password', route: '/settings/account' },
{ key: 'terms', label: 'Terms and Policies' },
{ key: 'support', label: 'Feedback and Support' },
];
export default function SettingsHomeScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const palette = palettes[colorScheme === 'dark' ? 'dark' : 'light'];
const [isLoggingOut, setIsLoggingOut] = useState(false);
const topInset = Math.max(insets.top, 16);
const bottomInset = Math.max(insets.bottom + 16, 40);
const handleNavigate = (destination: SettingDestination) => {
if (!destination.route) {
return;
}
router.push(destination.route);
};
const handleLogOut = async () => {
if (isLoggingOut) {
return;
}
setIsLoggingOut(true);
try {
await authClient.signOut();
router.replace('/(auth)/login');
} catch (error) {
const message =
error instanceof Error ? error.message : 'Please try again in a moment.';
Alert.alert('Unable to log out', message);
} finally {
setIsLoggingOut(false);
}
};
return (
<View style={[styles.screen, { paddingTop: topInset, backgroundColor: palette.canvas }]}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<View style={styles.headerRow}>
<Pressable
accessibilityRole="button"
style={styles.headerControl}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={22} color={palette.icon} />
</Pressable>
<Text style={[styles.headerTitle, { color: palette.textPrimary }]}>set up</Text>
<View style={styles.headerControl} />
</View>
<View style={styles.content}>
<View style={[styles.card, { backgroundColor: palette.card }]}>
{destinations.map((destination, index) => {
const isLast = index === destinations.length - 1;
return (
<Pressable
key={destination.key}
accessibilityRole={destination.route ? 'button' : 'none'}
disabled={!destination.route}
onPress={() => handleNavigate(destination)}
style={({ pressed }) => [
styles.optionRow,
!isLast && { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: palette.stroke },
pressed && destination.route && { opacity: 0.6 },
]}
>
<Text style={[styles.optionLabel, { color: palette.textPrimary }]}>
{destination.label}
</Text>
<Ionicons name="chevron-forward" size={18} color={palette.textSecondary} />
</Pressable>
);
})}
</View>
</View>
<View style={[styles.footer, { paddingBottom: bottomInset }]}>
<Pressable
accessibilityRole="button"
disabled={isLoggingOut}
onPress={handleLogOut}
style={({ pressed }) => [
styles.logoutButton,
{ backgroundColor: palette.buttonBackground },
pressed && { opacity: 0.9 },
]}
>
{isLoggingOut ? (
<ActivityIndicator size="small" color={palette.buttonText} />
) : (
<Text style={[styles.logoutLabel, { color: palette.buttonText }]}>Log out</Text>
)}
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
paddingHorizontal: 20,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 32,
},
headerControl: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
letterSpacing: 0.4,
},
content: {
flex: 1,
},
card: {
borderRadius: 18,
paddingHorizontal: 20,
},
optionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 64,
},
optionLabel: {
fontSize: 16,
fontWeight: '500',
},
footer: {
paddingTop: 32,
},
logoutButton: {
borderRadius: 26,
height: 56,
alignItems: 'center',
justifyContent: 'center',
},
logoutLabel: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -1,54 +1,46 @@
import { DynamicForm } from '@/components/forms/dynamic-form';
import Ionicons from '@expo/vector-icons/Ionicons';
import { ResultDisplay } from '@/components/template-run/result-display';
import { RunProgressView } from '@/components/template-run/run-progress';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { useTemplateFormData, useTemplateRun } from '@/hooks/use-template-run';
import { useTemplateRun } from '@/hooks/use-template-run';
import { getTemplateById } from '@/lib/api/templates';
import { subscription } from '@/lib/auth/client';
import { Template } from '@/lib/types/template';
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
import { RunTemplateData } from '@/lib/types/template-run';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert, BackHandler, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
import { Stack, useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, BackHandler, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type RunStep = 'form' | 'progress' | 'result';
type RunStep = 'progress' | 'result';
// 转换表单数据为 API 期望的格式
function transformFormData(formData: RunTemplateData): RunTemplateData {
const transformed: RunTemplateData = {};
Object.entries(formData).forEach(([fieldName, value]) => {
// 根据字段名称判断类型并转换
if (fieldName.startsWith('image_')) {
// 图片字段转换为对象格式
transformed[fieldName] = {
url: value,
type: 'image'
type: 'image',
};
} else if (fieldName.startsWith('text_')) {
// 文本字段转换为对象格式
transformed[fieldName] = {
content: value,
type: 'text'
type: 'text',
};
} else if (fieldName.startsWith('video_')) {
// 视频字段转换为对象格式
transformed[fieldName] = {
url: value,
type: 'video'
type: 'video',
};
} else if (fieldName.startsWith('color_')) {
// 颜色字段转换为对象格式
transformed[fieldName] = {
value: value,
type: 'color'
value,
type: 'color',
};
} else {
// 其他类型保持原样
transformed[fieldName] = value;
}
});
@@ -59,10 +51,14 @@ function transformFormData(formData: RunTemplateData): RunTemplateData {
export default function TemplateRunScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const globalParams = useGlobalSearchParams<{ formData?: string }>();
const insets = useSafeAreaInsets();
const [template, setTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(true);
const [currentStep, setCurrentStep] = useState<RunStep>('form');
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
const [currentStep, setCurrentStep] = useState<RunStep>('progress');
const [hasStarted, setHasStarted] = useState(false);
const [formImageUrls, setFormImageUrls] = useState<string[]>([]);
const {
progress,
@@ -71,20 +67,16 @@ export default function TemplateRunScreen() {
isLoading,
executeTemplate,
cancelRun,
reset,
cleanup,
} = useTemplateRun({
onSuccess: (result) => {
onSuccess: () => {
setCurrentStep('result');
},
onError: (error) => {
Alert.alert('运行失败', error.message);
onError: (runError) => {
Alert.alert('运行失败', runError.message);
},
});
const { formData, errors, updateField } = useTemplateFormData();
// 获取模板信息
useEffect(() => {
const loadTemplate = async () => {
if (!id) return;
@@ -94,43 +86,12 @@ export default function TemplateRunScreen() {
const response = await getTemplateById(id);
if (response.success && response.data) {
setTemplate(response.data);
// 解析表单配置
if (response.data.formSchema) {
try {
console.log('原始formSchema数据:', response.data.formSchema);
// 解析formSchema数据可能是字符串或对象
const rawData = typeof response.data.formSchema === 'string'
? JSON.parse(response.data.formSchema)
: response.data.formSchema;
// 使用转换工具将工作流数据转换为表单配置
const formConfig = transformWorkflowToFormSchema(rawData);
// 验证转换结果
if (validateFormSchema(formConfig)) {
console.log('转换后的表单配置:', formConfig);
setFormSchema(formConfig);
} else {
console.warn('表单配置验证失败,使用空配置');
setFormSchema({ fields: [] });
}
} catch (error) {
console.error('解析表单配置失败:', error);
console.log('使用空表单配置作为后备方案');
setFormSchema({ fields: [] });
}
} else {
console.log('模板没有formSchema使用空配置');
setFormSchema({ fields: [] });
}
} else {
throw new Error('模板不存在');
}
} catch (error) {
console.error('加载模板失败:', error);
Alert.alert('错误', '无法加载模板,请稍后重试');
} catch (templateError) {
console.error('加载模板失败:', templateError);
Alert.alert('错误', '无法加载模板,请稍后重试');
router.back();
} finally {
setLoading(false);
@@ -140,298 +101,324 @@ export default function TemplateRunScreen() {
loadTemplate();
}, [id, router]);
// 处理返回键
useEffect(() => {
const handleBackPress = () => {
if (currentStep === 'progress' && isLoading) {
Alert.alert(
'确认退出',
'任务正在运行中,确定要退出吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '退出',
style: 'destructive',
onPress: () => {
cancelRun();
router.back();
if (template && !hasStarted && globalParams.formData) {
try {
const formData = JSON.parse(globalParams.formData) as RunTemplateData;
const images = Object.entries(formData)
.filter(([fieldName, value]) => fieldName.startsWith('image_') && typeof value === 'string' && value.length > 0)
.map(([, value]) => value as string);
setFormImageUrls(images);
setHasStarted(true);
const executeWithData = async () => {
try {
const list = await subscription.list();
const listData = list.data ?? [];
if (listData.length === 0) {
Alert.alert('未检测到订阅', '请先完成订阅后再尝试生成内容。');
router.back();
return;
}
const subscriptionId = listData[0]?.stripeSubscriptionId;
if (!subscriptionId) {
Alert.alert('订阅信息缺失', '当前订阅缺少 Stripe 订阅标识,无法记录用量。');
router.back();
return;
}
await subscription.credit.summary({
subscriptionId,
filter: {
type: 'applicability_scope',
applicability_scope: {
price_type: 'metered',
},
},
},
]
);
return true;
}
return false;
};
});
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
return () => subscription.remove();
}, [currentStep, isLoading, cancelRun, router]);
const identify = await subscription.meterEvent({
event_name: 'token_usage',
payload: {
value: '100',
},
});
// 组件卸载时清理
useEffect(() => {
return cleanup;
}, [cleanup]);
const identifier = identify.data?.identifier;
if (!identifier) {
Alert.alert('用量记录失败', '无法生成用量凭证,请稍后重试。');
router.back();
return;
}
// 验证表单数据
const validateFormData = useCallback((): { isValid: boolean; message?: string } => {
if (!template) {
return { isValid: false, message: '模板信息未加载完成' };
}
const transformedData = transformFormData(formData);
await executeTemplate(template.id, transformedData);
} catch (executeError) {
console.error('执行模板失败:', executeError);
Alert.alert('执行失败', '运行模板时出现异常,请稍后重试。');
router.back();
}
};
if (Object.keys(errors).length > 0) {
return { isValid: false, message: `请修正 ${Object.keys(errors).length} 个错误后再提交` };
}
if (formSchema.fields?.length > 0) {
const requiredFields = formSchema.fields.filter(field => field.required);
const missingFields = requiredFields.filter(field => {
const value = formData[field.name];
return value === undefined || value === null || value === '';
});
if (missingFields.length > 0) {
const fieldNames = missingFields.map(f => f.label || f.name).join('、');
return { isValid: false, message: `请填写必填项:${fieldNames}` };
executeWithData();
} catch (parseError) {
console.error('解析表单数据失败:', parseError);
Alert.alert('数据错误', '表单数据格式错误,请重新提交。');
router.back();
}
}
}, [template, hasStarted, globalParams.formData, executeTemplate, router]);
return { isValid: true };
}, [template, errors, formSchema.fields, formData]);
// 确认对话框
const showConfirmDialog = useCallback((onConfirm: () => void) => {
if (Platform.OS === 'web') {
const confirmed = window.confirm('确定要开始生成任务吗?');
if (confirmed) onConfirm();
} else {
const handleRequestExit = useCallback(() => {
if (currentStep === 'progress' && isLoading) {
Alert.alert(
'确认提交',
'确定要开始生成任务吗?',
'确认退出',
'任务正在运行中,确定要退出吗?',
[
{ text: '取消', style: 'cancel' },
{ text: '确定', onPress: onConfirm }
{ text: '继续等待', style: 'cancel' },
{
text: '停止并返回',
style: 'destructive',
onPress: () => {
cancelRun();
router.back();
},
},
]
);
}
}, []);
// 提交表单
const handleSubmit = useCallback(() => {
const validation = validateFormData();
if (!validation.isValid) {
Alert.alert('表单错误', validation.message);
return;
return true;
}
const handleConfirm = async () => {
try {
const transformedData = transformFormData(formData);
console.log('转换后的数据:', transformedData);
router.back();
return true;
}, [cancelRun, currentStep, isLoading, router]);
const list = await subscription.list();
const listData = list.data ?? [];
if (listData.length === 0) {
Alert.alert('未检测到订阅', '请先完成订阅后再尝试生成内容。');
return;
}
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', handleRequestExit);
return () => subscription.remove();
}, [handleRequestExit]);
const subscriptionId = listData[0]?.stripeSubscriptionId;
if (!subscriptionId) {
Alert.alert('订阅信息缺失', '当前订阅缺少 Stripe 订阅标识,无法记录用量。');
return;
}
useEffect(() => cleanup, [cleanup]);
await subscription.credit.summary({
subscriptionId,
filter: {
type: 'applicability_scope',
applicability_scope: {
price_type: 'metered',
},
},
});
const identify = await subscription.meterEvent({
event_name: 'token_usage',
payload: {
value: '100',
},
});
const identifier = identify.data?.identifier;
if (!identifier) {
Alert.alert('用量记录失败', '无法生成用量凭证,请稍后重试。');
return;
}
try {
setCurrentStep('progress');
await executeTemplate(template!.id, transformedData);
} catch (error) {
console.error('执行模板失败:', error);
Alert.alert('执行失败', '运行模板时出现异常,请稍后重试。');
}
} catch (error) {
console.error('提交表单失败:', error);
Alert.alert('提交失败', '提交生成请求时出现问题,请稍后重试。');
}
};
showConfirmDialog(handleConfirm);
}, [validateFormData, showConfirmDialog, executeTemplate, template, formData]);
// 重新运行
const handleRerun = useCallback(() => {
setCurrentStep('form');
reset();
}, [reset]);
router.back();
}, [router]);
// 渲染表单步骤
const renderFormStep = () => (
<View style={styles.container}>
{/* 模板信息头部 */}
{template && (
<View style={styles.templateHeader}>
<LinearGradient
colors={['#4ECDC4', '#44A3A0']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.headerGradient}
>
<View style={styles.templateInfo}>
<Image
source={{ uri: template.coverImageUrl }}
style={styles.templateImage}
contentFit="cover"
/>
<View style={styles.templateDetails}>
<ThemedText style={styles.templateTitle}>
{template.title}
</ThemedText>
<ThemedText style={styles.templateDescription} numberOfLines={2}>
{template.description}
</ThemedText>
{template.category && (
<View style={styles.categoryBadge}>
<ThemedText style={styles.categoryText}>
{template.category.name}
</ThemedText>
</View>
)}
</View>
</View>
</LinearGradient>
</View>
)}
const progressMeta = useMemo(() => {
const rawProgress = typeof progress.progress === 'number' ? progress.progress : 0;
const normalized = Math.max(0, Math.min(rawProgress, 100));
return {
normalized,
percentLabel: Math.round(normalized),
fillWidth: normalized <= 0 ? 0 : Math.min(Math.max(normalized, 8), 100),
};
}, [progress.progress]);
{/* 表单内容 */}
<View style={styles.formContainer}>
<ThemedText style={styles.formTitle}></ThemedText>
{formSchema.fields && formSchema.fields.length > 0 ? (
<DynamicForm
schema={formSchema}
onDataChange={(data, valid) => {
// 实时更新表单数据到外部状态
Object.entries(data).forEach(([key, value]) => {
updateField(key, value);
});
}}
/>
const narrative = useMemo(() => {
const fallbackTitle = 'Insert Your Product Into An ASMR Video';
const fallbackDescription =
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.';
const rawTitle = template?.titleEn ?? template?.title ?? fallbackTitle;
const rawDescription = template?.descriptionEn ?? template?.description ?? fallbackDescription;
return {
title: rawTitle.toUpperCase(),
description: rawDescription,
};
}, [template]);
const imagery = useMemo(() => {
const fallback = template?.previewUrl ?? template?.coverImageUrl ?? undefined;
const primary = formImageUrls[0] ?? fallback;
const secondary = formImageUrls[1] ?? template?.previewUrl ?? primary;
return {
hero: primary,
overlay: secondary,
preview: template?.previewUrl ?? primary ?? fallback,
};
}, [formImageUrls, template]);
const renderProgressStep = () => (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.progressContent,
{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 40,
},
]}
showsVerticalScrollIndicator={false}
>
<TouchableOpacity
onPress={handleRequestExit}
activeOpacity={0.8}
style={[styles.backButton, styles.backButtonProgress]}
>
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
</TouchableOpacity>
<View style={styles.titleSection}>
<ThemedText style={styles.title} lightColor="#FFFFFF" darkColor="#FFFFFF">
{narrative.title}
</ThemedText>
<ThemedText
style={styles.subtitle}
lightColor="rgba(255,255,255,0.68)"
darkColor="rgba(255,255,255,0.68)"
>
{narrative.description}
</ThemedText>
</View>
<View style={styles.heroContainer}>
{imagery.hero ? (
<Image source={{ uri: imagery.hero }} style={styles.heroImage} contentFit="cover" />
) : (
<View style={styles.noConfigContainer}>
<ThemedText style={styles.noConfigText}>
使
<View style={styles.heroPlaceholder} />
)}
{imagery.overlay && (
<View style={styles.overlayImageFrame}>
<Image source={{ uri: imagery.overlay }} style={styles.overlayImage} contentFit="cover" />
</View>
)}
</View>
<LinearGradient colors={['#1E1E21', '#101015']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.progressCard}>
{imagery.preview ? (
<View style={styles.previewFrame}>
<Image source={{ uri: imagery.preview }} style={styles.previewImage} contentFit="cover" />
</View>
) : (
<View style={[styles.previewFrame, styles.previewPlaceholder]} />
)}
<View style={styles.progressCopy}>
<ThemedText style={styles.progressHeadline} lightColor="#FFFFFF" darkColor="#FFFFFF">
...
</ThemedText>
<ThemedText
style={styles.progressMessage}
lightColor="rgba(255,255,255,0.72)"
darkColor="rgba(255,255,255,0.72)"
>
{progress.message || '正在为你织造 ASMR 场景,请稍候片刻。'}
</ThemedText>
</View>
<View style={styles.progressBarArea}>
<View style={styles.progressTrack}>
{progressMeta.fillWidth > 0 && (
<View style={[styles.progressFill, { width: `${progressMeta.fillWidth}%` }]}>
<LinearGradient
colors={['#78F8FF', '#49A7FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</View>
)}
</View>
<ThemedText
style={styles.progressPercent}
lightColor="rgba(255,255,255,0.6)"
darkColor="rgba(255,255,255,0.6)"
>
{progressMeta.percentLabel}%
</ThemedText>
</View>
</LinearGradient>
<TouchableOpacity
activeOpacity={0.85}
style={styles.primaryButton}
onPress={() => {
Alert.alert('生成进行中', '系统正在为你制作视频,请耐心等待。');
}}
>
<ThemedText style={styles.primaryButtonText} lightColor="#050505" darkColor="#050505">
Generate Video
</ThemedText>
</TouchableOpacity>
</ScrollView>
);
const renderResultStep = () => (
<View
style={[
styles.resultContainer,
{
paddingTop: insets.top + 16,
paddingBottom: Math.max(insets.bottom, 24),
},
]}
>
<View style={styles.resultHeader}>
<TouchableOpacity
onPress={handleRequestExit}
activeOpacity={0.8}
style={[styles.backButton, styles.backButtonResult]}
>
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
</TouchableOpacity>
<ThemedText style={styles.resultTitle} lightColor="#FFFFFF" darkColor="#FFFFFF">
</ThemedText>
</View>
<View style={styles.resultBody}>
{result ? (
<ResultDisplay result={result} onRerun={handleRerun} />
) : (
<View style={styles.stateContainer}>
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
...
</ThemedText>
</View>
)}
{/* 提交按钮 */}
<View style={styles.submitContainer}>
<TouchableOpacity style={styles.submitButton} onPress={handleSubmit}>
<ThemedText style={styles.submitButtonText}>
{isLoading ? '处理中...' : '开始生成'}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</View>
);
// 渲染进度步骤
const renderProgressStep = () => (
<View style={styles.container}>
<RunProgressView
progress={progress}
onCancel={() => {
Alert.alert(
'确认取消',
'确定要取消当前任务吗?',
[
{ text: '继续执行', style: 'cancel' },
{
text: '取消任务',
style: 'destructive',
onPress: cancelRun,
},
]
);
}}
/>
</View>
);
// 渲染结果步骤
const renderResultStep = () => (
<View style={styles.container}>
{result && (
<ResultDisplay
result={result}
onShare={(url) => {
// 可以添加自定义分享逻辑
}}
onDownload={(url) => {
// 可以添加自定义下载逻辑
}}
onRerun={handleRerun}
/>
)}
</View>
);
// 加载状态
if (loading) {
return (
<ThemedView style={styles.loadingContainer}>
<ThemedText>...</ThemedText>
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.stateContainer}>
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
...
</ThemedText>
</View>
</ThemedView>
);
}
// 错误状态
if (error && !template) {
return (
<ThemedView style={styles.errorContainer}>
<ThemedText style={styles.errorText}>
{error.message}
</ThemedText>
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.stateContainer}>
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
{error.message}
</ThemedText>
</View>
</ThemedView>
);
}
// 渲染当前步骤
return (
<ThemedView style={styles.container}>
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
<Stack.Screen
options={{
title: currentStep === 'form' ? '配置模板' :
currentStep === 'progress' ? '生成中' : '生成结果',
headerBackVisible: currentStep !== 'progress' || !isLoading,
headerShown: false,
gestureEnabled: currentStep !== 'progress' || !isLoading,
}}
/>
{currentStep === 'form' && renderFormStep()}
{currentStep === 'progress' && renderProgressStep()}
{currentStep === 'result' && renderResultStep()}
</ThemedView>
@@ -439,112 +426,182 @@ export default function TemplateRunScreen() {
}
const styles = StyleSheet.create({
container: {
screen: {
flex: 1,
backgroundColor: '#050505',
},
scroll: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
progressContent: {
paddingHorizontal: 24,
gap: 28,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
backgroundColor: 'rgba(255,255,255,0.05)',
alignItems: 'center',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
textAlign: 'center',
opacity: 0.7,
backButtonProgress: {
alignSelf: 'flex-start',
},
templateHeader: {
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
backButtonResult: {
marginRight: 12,
},
titleSection: {
gap: 12,
},
title: {
fontSize: 20,
fontWeight: '700',
letterSpacing: 0.8,
lineHeight: 28,
},
subtitle: {
fontSize: 13,
lineHeight: 20,
},
heroContainer: {
position: 'relative',
borderRadius: 24,
overflow: 'hidden',
backgroundColor: '#121212',
},
heroImage: {
width: '100%',
aspectRatio: 1.58,
},
heroPlaceholder: {
width: '100%',
aspectRatio: 1.58,
backgroundColor: '#1F1F20',
},
overlayImageFrame: {
position: 'absolute',
left: 18,
bottom: 18,
width: 72,
height: 72,
borderRadius: 18,
overflow: 'hidden',
borderWidth: 2,
borderColor: '#050505',
shadowColor: '#000000',
shadowOpacity: 0.4,
shadowRadius: 8,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
headerGradient: {
paddingTop: 20,
paddingBottom: 24,
paddingHorizontal: 20,
overlayImage: {
width: '100%',
height: '100%',
},
templateInfo: {
progressCard: {
borderRadius: 28,
paddingVertical: 28,
paddingHorizontal: 24,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.06)',
gap: 24,
},
previewFrame: {
alignSelf: 'center',
width: 96,
height: 148,
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
backgroundColor: '#0F0F12',
},
previewImage: {
width: '100%',
height: '100%',
},
previewPlaceholder: {
backgroundColor: '#18181C',
},
progressCopy: {
alignItems: 'center',
gap: 6,
paddingHorizontal: 8,
},
progressHeadline: {
fontSize: 16,
fontWeight: '700',
},
progressMessage: {
fontSize: 13,
lineHeight: 20,
textAlign: 'center',
},
progressBarArea: {
gap: 12,
},
progressTrack: {
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 4,
overflow: 'hidden',
},
progressPercent: {
fontSize: 12,
fontWeight: '600',
textAlign: 'right',
},
primaryButton: {
height: 60,
borderRadius: 30,
backgroundColor: '#D7FF1F',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#D7FF1F',
shadowOpacity: 0.4,
shadowRadius: 16,
shadowOffset: { width: 0, height: 12 },
elevation: 4,
},
primaryButtonText: {
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.6,
},
resultContainer: {
flex: 1,
paddingHorizontal: 24,
gap: 24,
},
resultHeader: {
flexDirection: 'row',
alignItems: 'center',
},
templateImage: {
width: 60,
height: 60,
borderRadius: 12,
marginRight: 16,
},
templateDetails: {
flex: 1,
},
templateTitle: {
fontSize: 18,
fontWeight: '600',
color: '#fff',
marginBottom: 4,
},
templateDescription: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginBottom: 8,
},
categoryBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
alignSelf: 'flex-start',
},
categoryText: {
fontSize: 12,
color: '#fff',
fontWeight: '500',
},
formContainer: {
flex: 1,
padding: 20,
},
formTitle: {
fontSize: 20,
fontWeight: '600',
marginBottom: 16,
},
noConfigContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: 12,
padding: 24,
alignItems: 'center',
marginBottom: 24,
},
noConfigText: {
resultTitle: {
fontSize: 16,
fontWeight: '600',
letterSpacing: 0.4,
},
resultBody: {
flex: 1,
},
stateContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
},
stateText: {
fontSize: 15,
lineHeight: 22,
textAlign: 'center',
opacity: 0.7,
},
submitContainer: {
marginTop: 'auto',
paddingTop: 20,
},
submitButton: {
backgroundColor: '#4ECDC4',
borderRadius: 12,
paddingVertical: 16,
alignItems: 'center',
shadowColor: '#4ECDC4',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
submitButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
});

295
app/templates/[id].tsx Normal file
View File

@@ -0,0 +1,295 @@
import { router, useLocalSearchParams } from 'expo-router';
import React, { useMemo, useState } from 'react';
import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
type TemplateFrame = {
id: string;
title: string;
uri: string;
accent: string;
};
const curatedLooks: TemplateFrame[] = [
{
id: 'earth-zoom',
title: 'Earth Zoom Our',
uri: 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=1200&q=80',
accent: '#D1FF00',
},
{
id: 'midnight-atelier',
title: 'Midnight Atelier',
uri: 'https://images.unsplash.com/photo-1500336624523-d727130c3328?auto=format&fit=crop&w=1200&q=80',
accent: '#FF7A45',
},
{
id: 'flora-couture',
title: 'Flora Couture',
uri: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1200&q=80',
accent: '#7A8BFF',
},
{
id: 'noir-silhouette',
title: 'Noir Silhouette',
uri: 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&w=1200&q=80',
accent: '#FF5F6D',
},
{
id: 'aurora-thread',
title: 'Aurora Thread',
uri: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=1200&q=80',
accent: '#21D4FD',
},
{
id: 'velvet-echo',
title: 'Velvet Echo',
uri: 'https://images.unsplash.com/photo-1512436991641-6745cdb1723f?auto=format&fit=crop&w=1200&q=80',
accent: '#FFAA00',
},
];
const quickActions = [
{ id: 'upscale', label: 'Upscale' },
{ id: 'collect', label: 'Collect' },
{ id: 'download', label: 'Download' },
];
export default function TemplateDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [selectedId, setSelectedId] = useState<string>(curatedLooks[0].id);
const [menuVisible, setMenuVisible] = useState(true);
const current = useMemo(
() => curatedLooks.find(look => look.id === selectedId) ?? curatedLooks[0],
[selectedId],
);
const resolvedTemplateId = typeof id === 'string' && id.length > 0 ? id : current.id;
const handleGenerate = () => {
router.push(`/templates/${resolvedTemplateId}/form`);
};
return (
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.topCarousel}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.carouselContent}
>
{curatedLooks.map(look => {
const isActive = look.id === selectedId;
return (
<TouchableOpacity
key={look.id}
activeOpacity={0.85}
onPress={() => setSelectedId(look.id)}
style={[
styles.previewFrame,
{ borderColor: isActive ? look.accent : 'rgba(255,255,255,0.08)' },
isActive && styles.previewFrameActive,
]}
>
<Image source={{ uri: look.uri }} style={styles.previewImage} />
</TouchableOpacity>
);
})}
</ScrollView>
</View>
<View style={styles.heroStage}>
<Image source={{ uri: current.uri }} style={styles.heroImage} />
</View>
<View style={styles.bottomBar}>
<View style={styles.infoCard}>
<Image source={{ uri: current.uri }} style={styles.infoThumbnail} />
<Text style={styles.infoTitle} numberOfLines={1}>
{current.title}
</Text>
</View>
<TouchableOpacity
style={styles.generateButton}
activeOpacity={0.9}
onPress={handleGenerate}
>
<Text style={styles.generateLabel}>Generate Video</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.menuTrigger}
activeOpacity={0.85}
onPress={() => setMenuVisible(prev => !prev)}
>
<View style={styles.menuDots}>
<View style={styles.dot} />
<View style={[styles.dot, styles.dotMiddle]} />
<View style={styles.dot} />
</View>
</TouchableOpacity>
</View>
</ScrollView>
{menuVisible && (
<View style={styles.menuPanel}>
{quickActions.map(action => (
<TouchableOpacity
key={action.id}
activeOpacity={0.85}
style={styles.menuItem}
onPress={() => setMenuVisible(false)}
>
<Text style={styles.menuLabel}>{action.label}</Text>
</TouchableOpacity>
))}
</View>
)}
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
canvas: {
flex: 1,
backgroundColor: '#040404',
},
safeArea: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 24,
paddingBottom: 120,
},
topCarousel: {
marginTop: 12,
paddingVertical: 8,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
carouselContent: {
paddingHorizontal: 12,
alignItems: 'center',
gap: 12,
},
previewFrame: {
width: 56,
height: 56,
borderRadius: 18,
borderWidth: 1,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
previewFrameActive: {
transform: [{ scale: 1.05 }],
},
previewImage: {
width: '100%',
height: '100%',
},
heroStage: {
marginTop: 32,
borderRadius: 36,
backgroundColor: '#111111',
overflow: 'hidden',
shadowColor: '#000000',
shadowOpacity: 0.45,
shadowRadius: 32,
shadowOffset: { width: 0, height: 18 },
elevation: 24,
},
heroImage: {
width: '100%',
height: 456,
borderRadius: 36,
},
bottomBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginTop: 32,
},
infoCard: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
infoThumbnail: {
width: 32,
height: 32,
borderRadius: 10,
marginRight: 12,
},
infoTitle: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
maxWidth: 120,
},
generateButton: {
flex: 1,
height: 56,
borderRadius: 28,
backgroundColor: '#D1FF00',
alignItems: 'center',
justifyContent: 'center',
},
generateLabel: {
fontSize: 16,
fontWeight: '700',
color: '#050505',
letterSpacing: 0.3,
},
menuTrigger: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
alignItems: 'center',
justifyContent: 'center',
},
menuDots: {
justifyContent: 'space-between',
height: 24,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#FFFFFF',
},
dotMiddle: {
opacity: 0.7,
},
menuPanel: {
position: 'absolute',
right: 24,
bottom: 160,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 20,
backgroundColor: 'rgba(10, 10, 10, 0.94)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
gap: 12,
},
menuItem: {
paddingVertical: 4,
},
menuLabel: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});

709
app/templates/[id]/form.tsx Normal file
View File

@@ -0,0 +1,709 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { ArrowLeft, UploadCloud } from 'lucide-react';
import { Feather } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { DynamicForm } from '@/components/forms/dynamic-form';
import { getTemplateById } from '@/lib/api/templates';
import { Template } from '@/lib/types/template';
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
interface Step1FormData {
characterImage: string;
productImage: string;
script: string;
}
interface Step2FormData extends RunTemplateData {}
interface FormData {
step1: Step1FormData;
step2: Step2FormData;
}
type UploadFieldKey = 'characterImage' | 'productImage';
const FALLBACK_PREVIEW =
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
const FALLBACK_INSET =
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=640&q=80';
export default function TemplateFormScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [template, setTemplate] = useState<Template | null>(null);
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState<FormData>({
step1: {
characterImage: '',
productImage: '',
script: '',
},
step2: {},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
loadInitialData();
}, [id]);
const loadInitialData = async () => {
try {
setLoading(true);
const templateResponse = await getTemplateById(id);
if (templateResponse.success && templateResponse.data) {
setTemplate(templateResponse.data);
if (templateResponse.data.formSchema) {
try {
const rawData =
typeof templateResponse.data.formSchema === 'string'
? JSON.parse(templateResponse.data.formSchema as string)
: templateResponse.data.formSchema;
const formConfig = transformWorkflowToFormSchema(rawData);
if (validateFormSchema(formConfig)) {
setFormSchema(formConfig);
}
} catch (error) {
console.error('Failed to parse form schema:', error);
}
}
}
} catch (error) {
console.error('Failed to load template:', error);
Alert.alert('Error', 'Failed to load data. Please retry later.');
} finally {
setLoading(false);
}
};
const validateStep1 = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.step1.characterImage) {
newErrors.characterImage = 'Please upload the character image';
}
if (!formData.step1.productImage) {
newErrors.productImage = 'Please upload the product image';
}
if (!formData.step1.script.trim()) {
newErrors.script = 'Please provide the character script';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleStep1Next = () => {
if (validateStep1()) {
setCurrentStep(2);
}
};
const handleStep2Prev = () => {
setCurrentStep(1);
};
const handleStep2Submit = async () => {
if (!template) return;
setIsSubmitting(true);
try {
const combinedData = {
...formData.step1,
...formData.step2,
};
console.log('Submitting template payload:', combinedData);
Alert.alert('Success', 'Template configuration saved.', [
{
text: 'OK',
onPress: () => {
router.push(`/templates/${id}/run`);
},
},
]);
} catch (error) {
console.error('Submission failed:', error);
Alert.alert('Error', 'Submission failed. Please retry later.');
} finally {
setIsSubmitting(false);
}
};
const handleImagePick = async (field: UploadFieldKey) => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert(
'Permission Required',
'Please allow photo library access in Settings to upload images.',
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.9,
});
if (result.canceled || !result.assets || result.assets.length === 0) {
return;
}
const uri = result.assets[0].uri;
setFormData(prev => ({
...prev,
step1: {
...prev.step1,
[field]: uri,
},
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: '',
}));
}
} catch (error) {
console.error('Failed to pick image:', error);
Alert.alert('Error', 'Unable to pick image. Please try again later.');
}
};
const renderUploadCard = (
field: UploadFieldKey,
label: string,
description: string,
) => {
const value = formData.step1[field];
return (
<View style={styles.uploadItem}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => handleImagePick(field)}
style={[
styles.uploadCard,
value && styles.uploadCardFilled,
errors[field] && styles.uploadCardError,
]}
>
{value ? (
<Image source={{ uri: value }} style={styles.uploadImage} />
) : (
<>
<View style={styles.uploadIconWrap}>
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
</View>
<Text style={styles.uploadLabel}>{label}</Text>
<Text style={styles.uploadDescription}>{description}</Text>
</>
)}
</TouchableOpacity>
{errors[field] ? (
<Text style={styles.inlineError}>{errors[field]}</Text>
) : null}
</View>
);
};
const renderStep1 = () => {
const heroImage = template?.previewUrl || template?.coverImageUrl || FALLBACK_PREVIEW;
const insetImage = template?.coverImageUrl || FALLBACK_INSET;
return (
<ScrollView
style={styles.stepScroll}
contentContainerStyle={styles.step1Content}
showsVerticalScrollIndicator={false}
>
<View style={styles.topBar}>
<TouchableOpacity
activeOpacity={0.85}
onPress={() => router.back()}
style={styles.backButton}
>
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
</TouchableOpacity>
</View>
<Text style={styles.heroTitle}>
{(template?.title ?? 'Insert your product into an ASMR video').toUpperCase()}
</Text>
<Text style={styles.heroSubtitle}>
{template?.description ||
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.'}
</Text>
<View style={styles.previewStage}>
<Image source={{ uri: heroImage }} style={styles.previewImage} />
<View style={styles.previewInset}>
<Image source={{ uri: insetImage }} style={styles.previewInsetImage} />
</View>
</View>
<View style={styles.uploadRow}>
{renderUploadCard(
'characterImage',
'UPLOAD CHARACTER IMAGE',
'PNG, JPG or Paste from clipboard',
)}
{renderUploadCard(
'productImage',
'UPLOAD CHARACTER IMAGE',
'PNG, JPG or Paste from clipboard',
)}
</View>
<View style={styles.promptField}>
<TextInput
style={[styles.promptInput, errors.script && styles.promptInputError]}
placeholder="What should your character say?"
placeholderTextColor="rgba(255, 255, 255, 0.5)"
value={formData.step1.script}
onChangeText={text => {
setFormData(prev => ({
...prev,
step1: {
...prev.step1,
script: text,
},
}));
if (errors.script) {
setErrors(prev => ({
...prev,
script: '',
}));
}
}}
multiline
textAlignVertical="top"
/>
{errors.script ? <Text style={styles.inlineError}>{errors.script}</Text> : null}
</View>
<TouchableOpacity
style={styles.generateButton}
activeOpacity={0.88}
onPress={handleStep1Next}
>
<Feather name="star" size={20} color="#050505" />
<Text style={styles.generateLabel}>Generate Video</Text>
</TouchableOpacity>
</ScrollView>
);
};
const renderStep2 = () => (
<View style={styles.step2Wrapper}>
<View style={styles.topBar}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleStep2Prev}
style={styles.backButton}
>
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
</TouchableOpacity>
<Text style={styles.stepIndicator}>STEP 2 OF 2</Text>
</View>
<Text style={styles.step2Title}>Run Parameters</Text>
<Text style={styles.step2Subtitle}>
Fine-tune the details so the ASMR video delivers the mood you expect.
</Text>
<View style={styles.step2FormShell}>
{formSchema.fields.length > 0 ? (
<DynamicForm
schema={formSchema}
initialData={formData.step2}
onDataChange={data => {
setFormData(prev => ({
...prev,
step2: data,
}));
}}
/>
) : (
<View style={styles.emptyState}>
<Text style={styles.emptyIcon}>📋</Text>
<Text style={styles.emptyText}>No extra parameters</Text>
<Text style={styles.emptyDescription}>
This template is ready to gostart generating the ASMR experience instantly.
</Text>
</View>
)}
</View>
</View>
);
const renderBottomActions = () => {
if (currentStep !== 2) {
return null;
}
return (
<SafeAreaView edges={['bottom']} style={styles.bottomSafeArea}>
<View style={styles.bottomActions}>
<TouchableOpacity
style={styles.secondaryButton}
activeOpacity={0.85}
onPress={handleStep2Prev}
>
<Text style={styles.secondaryLabel}>Back</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.submitButton}
activeOpacity={0.9}
onPress={handleStep2Submit}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator color="#050505" />
) : (
<>
<Feather name="zap" size={18} color="#050505" />
<Text style={styles.submitLabel}>Generate Video</Text>
</>
)}
</TouchableOpacity>
</View>
</SafeAreaView>
);
};
if (loading) {
return (
<View style={styles.loadingCanvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#D1FF00" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
</SafeAreaView>
</View>
);
}
return (
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
{currentStep === 1 ? renderStep1() : renderStep2()}
</SafeAreaView>
{renderBottomActions()}
</View>
);
}
const styles = StyleSheet.create({
canvas: {
flex: 1,
backgroundColor: '#050505',
},
safeArea: {
flex: 1,
},
loadingCanvas: {
flex: 1,
backgroundColor: '#050505',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: 'rgba(255, 255, 255, 0.7)',
letterSpacing: 0.3,
},
stepScroll: {
flex: 1,
},
step1Content: {
paddingHorizontal: 24,
paddingBottom: 40,
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 28,
},
backButton: {
width: 48,
height: 48,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(18, 18, 18, 0.78)',
alignItems: 'center',
justifyContent: 'center',
},
stepIndicator: {
flex: 1,
textAlign: 'right',
fontSize: 13,
letterSpacing: 2,
color: 'rgba(255, 255, 255, 0.5)',
},
heroTitle: {
fontSize: 26,
lineHeight: 32,
fontWeight: '700',
color: '#FFFFFF',
letterSpacing: 0.8,
textTransform: 'uppercase',
marginBottom: 16,
},
heroSubtitle: {
fontSize: 14,
lineHeight: 20,
color: 'rgba(255, 255, 255, 0.68)',
marginBottom: 24,
},
previewStage: {
borderRadius: 32,
overflow: 'hidden',
backgroundColor: '#111318',
marginBottom: 28,
},
previewImage: {
width: '100%',
height: 220,
},
previewInset: {
position: 'absolute',
bottom: 18,
left: 18,
width: 74,
height: 74,
borderRadius: 24,
borderWidth: 2,
borderColor: '#050505',
backgroundColor: '#050505',
overflow: 'hidden',
},
previewInsetImage: {
width: '100%',
height: '100%',
},
uploadRow: {
flexDirection: 'row',
gap: 18,
marginBottom: 24,
},
uploadItem: {
flex: 1,
},
uploadCard: {
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
backgroundColor: 'rgba(17, 19, 24, 0.95)',
paddingHorizontal: 20,
paddingVertical: 28,
alignItems: 'center',
justifyContent: 'center',
minHeight: 188,
overflow: 'hidden',
},
uploadCardFilled: {
paddingHorizontal: 0,
paddingVertical: 0,
},
uploadCardError: {
borderColor: '#FF6B6B',
},
uploadIconWrap: {
width: 60,
height: 60,
borderRadius: 22,
backgroundColor: 'rgba(209, 255, 0, 0.12)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
uploadLabel: {
fontSize: 14,
fontWeight: '700',
letterSpacing: 0.6,
color: '#FFFFFF',
textAlign: 'center',
textTransform: 'uppercase',
marginBottom: 6,
},
uploadDescription: {
fontSize: 12,
lineHeight: 16,
color: 'rgba(255, 255, 255, 0.55)',
textAlign: 'center',
},
uploadImage: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
promptField: {
marginBottom: 18,
},
promptInput: {
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
backgroundColor: 'rgba(17, 19, 24, 0.95)',
minHeight: 140,
paddingHorizontal: 22,
paddingVertical: 22,
fontSize: 15,
lineHeight: 22,
color: '#FFFFFF',
},
promptInputError: {
borderColor: '#FF6B6B',
},
inlineError: {
marginTop: 8,
fontSize: 12,
color: '#FF6B6B',
letterSpacing: 0.2,
},
generateButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
height: 58,
borderRadius: 30,
backgroundColor: '#D1FF00',
gap: 10,
},
generateLabel: {
fontSize: 17,
fontWeight: '700',
letterSpacing: 0.4,
color: '#050505',
},
step2Wrapper: {
flex: 1,
paddingHorizontal: 24,
paddingBottom: 24,
},
step2Title: {
fontSize: 22,
fontWeight: '700',
color: '#FFFFFF',
letterSpacing: 0.6,
marginBottom: 10,
},
step2Subtitle: {
fontSize: 13,
lineHeight: 18,
color: 'rgba(255, 255, 255, 0.6)',
marginBottom: 20,
},
step2FormShell: {
flex: 1,
borderRadius: 32,
backgroundColor: 'rgba(17, 19, 24, 0.78)',
overflow: 'hidden',
},
emptyState: {
flex: 1,
paddingHorizontal: 32,
alignItems: 'center',
justifyContent: 'center',
},
emptyIcon: {
fontSize: 48,
marginBottom: 16,
color: '#FFFFFF',
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 8,
},
emptyDescription: {
fontSize: 14,
lineHeight: 20,
color: 'rgba(255, 255, 255, 0.6)',
textAlign: 'center',
},
bottomSafeArea: {
backgroundColor: '#050505',
},
bottomActions: {
flexDirection: 'row',
gap: 16,
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 24,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
},
secondaryButton: {
flex: 1,
height: 56,
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
alignItems: 'center',
justifyContent: 'center',
},
secondaryLabel: {
fontSize: 15,
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.78)',
letterSpacing: 0.2,
},
submitButton: {
flex: 1.6,
height: 56,
borderRadius: 28,
backgroundColor: '#D1FF00',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
},
submitLabel: {
fontSize: 16,
fontWeight: '700',
color: '#050505',
letterSpacing: 0.3,
},
});

View File

@@ -33,6 +33,7 @@
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.8",
"lucide-react": "^0.548.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",
@@ -1492,6 +1493,8 @@
"lru-cache": ["lru-cache@6.0.0", "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"lucide-react": ["lucide-react@0.548.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA=="],
"make-error": ["make-error@1.3.6", "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
"makeerror": ["makeerror@1.0.12", "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],

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

View File

@@ -41,6 +41,113 @@ export const Colors = {
imagePlaceholder: '#2C2E2F',
error: '#ff453a',
},
brand: {
primary: '#007AFF',
secondary: '#4ECDC4',
points: '#FF6B6B',
success: '#07C160',
danger: '#FF4444',
warning: '#FF9500',
},
text: {
primary: '#333333',
secondary: '#666666',
tertiary: '#999999',
},
background: {
primary: '#F5F5F5',
secondary: '#FFFFFF',
tertiary: '#FAFAFA',
},
border: '#E0E0E0',
};
export const Spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 20,
xl: 24,
xxl: 32,
};
export const BorderRadius = {
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 28,
full: 999,
};
export const FontSize = {
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 20,
xxl: 24,
title: 28,
};
export const Shadow = {
small: {
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
medium: {
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
large: {
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
},
};
export const Animation = {
scale: {
pressed: 0.98,
},
duration: {
fast: 200,
normal: 300,
slow: 500,
},
};
export const Layout = {
headerHeight: 56,
buttonHeight: {
small: 36,
medium: 48,
large: 56,
},
tabBarHeight: 60,
safeAreaPadding: {
top: 16,
bottom: 16,
left: 16,
right: 16,
},
};
export const Fonts = Platform.select({
@@ -67,3 +174,13 @@ export const Fonts = Platform.select({
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});
export const Theme = {
colors: Colors,
spacing: Spacing,
borderRadius: BorderRadius,
fontSize: FontSize,
shadow: Shadow,
animation: Animation,
layout: Layout,
};

View File

@@ -1,393 +0,0 @@
# 认证系统使用指南
## 📋 概述
新的认证系统提供了从底部弹出登录Modal的体验替代原来的页面重定向。主要组件包括
- `AuthProvider` - 全局认证上下文提供者
- `useAuthGuard` - 认证拦截Hook
- `AuthGuard` - 认证守卫组件
- `AuthPrompt` - 用户引导组件
- `tokenManager` - Token管理器
## 🚀 快速开始
### 1. 在根布局中启用认证系统
```typescript
// app/_layout.tsx
import { AuthProvider } from '@/components/auth/auth-provider';
export default function RootLayout() {
return (
<ThemeProvider>
<AuthProvider>
{/* 你的应用内容 */}
</AuthProvider>
</ThemeProvider>
);
}
```
### 2. 基本用法
#### 使用 `useAuthGuard` Hook
```typescript
import { useAuthGuard } from '@/hooks/use-auth-guard';
function MyComponent() {
const { requireAuth } = useAuthGuard();
const handleProtectedAction = async () => {
const canProceed = requireAuth(async () => {
// 登录后会执行这里
await doSomething();
});
if (!canProceed) {
// 登录Modal会自动弹出
console.log('需要登录');
return;
}
// 已登录,直接执行
await doSomething();
};
return (
<Button title="执行受保护的操作" onPress={handleProtectedAction} />
);
}
```
#### 使用 `AuthGuard` 组件
```typescript
import { AuthGuard } from '@/components/auth/auth-guard';
function ProtectedContent() {
return (
<AuthGuard
title="需要登录"
subtitle="请先登录以使用此功能"
>
<ExpensiveComponent />
</AuthGuard>
);
}
```
#### 使用 `AuthPrompt` 组件
```typescript
import { AuthPrompt } from '@/components/auth/auth-prompt';
function MyScreen() {
return (
<View>
<AuthPrompt
variant="card"
title="登录后使用"
subtitle="解锁所有功能"
actionLabel="立即登录"
/>
</View>
);
}
```
## 📚 详细API
### AuthProvider
全局认证上下文提供者管理登录状态和Modal显示。
```typescript
// Props
interface AuthProviderProps {
children: React.ReactNode;
}
```
### useAuthGuard Hook
提供认证拦截功能。
```typescript
interface UseAuthGuard {
requireAuth: (callback?: () => void) => boolean;
pendingCallback: (() => void) | null;
}
// 返回值
{
requireAuth: (callback?: () => void) => boolean,
pendingCallback: (() => void) | null,
}
```
**参数:**
- `callback` - 登录成功后要执行的回调函数
**返回值:**
- `boolean` - 如果用户已登录,返回`true`并立即执行callback如果未登录返回`false`并弹出登录Modal
### AuthGuard 组件
包装需要认证的内容。
```typescript
interface AuthGuardProps {
children: React.ReactNode;
fallback?: React.ReactNode;
showLoginPrompt?: boolean;
title?: string;
subtitle?: string;
}
```
### AuthPrompt 组件
提供用户引导的登录提示。
```typescript
interface AuthPromptProps {
variant?: 'card' | 'fullscreen' | 'inline';
title?: string;
subtitle?: string;
actionLabel?: string;
showIcon?: boolean;
iconName?: keyof typeof Ionicons.glyphMap;
gradientColors?: string[];
onLoginPress?: () => void;
}
```
### Token管理器
```typescript
// 设置token
await tokenManager.setToken(token, expiresIn, refreshToken);
// 获取token
const tokenString = await tokenManager.getTokenString();
// 检查token有效性
const isValid = await tokenManager.isTokenValid();
// 清除token
await tokenManager.clearToken();
```
### API客户端
```typescript
import { apiClient, apiRequest } from '@/lib/api/client';
// 带认证的请求
const data = await apiClient('/api/protected', {
method: 'POST',
data: { ... },
});
// 标准请求(可以指定是否需要认证)
const data = await apiRequest('/api/public', {
requiresAuth: true,
});
```
## 🎨 最佳实践
### 1. 页面级认证
对于需要认证的页面:
```typescript
function ProtectedPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return (
<AuthPrompt
variant="fullscreen"
title="需要登录"
subtitle="请先登录以访问此页面"
/>
);
}
return <PageContent />;
}
```
### 2. 功能级认证
对于需要认证的特定功能:
```typescript
function FeatureButton() {
const { requireAuth } = useAuthGuard();
const handleClick = () => {
requireAuth(() => {
// 执行需要登录的功能
executeFeature();
});
};
return <Button title="使用功能" onPress={handleClick} />;
}
```
### 3. API请求认证
```typescript
// 推荐使用apiClient它会自动添加认证头
const data = await apiClient('/api/user/profile');
// 或使用apiRequest并指定requiresAuth
const data = await apiRequest('/api/data', {
requiresAuth: true,
});
```
### 4. 条件渲染
```typescript
function ConditionalContent() {
const { isAuthenticated } = useAuth();
return (
<View>
{isAuthenticated ? (
<ProtectedContent />
) : (
<AuthPrompt variant="card" />
)}
</View>
);
}
```
## 🔧 配置选项
### AuthPrompt 样式自定义
```typescript
<AuthPrompt
variant="card"
title="自定义标题"
subtitle="自定义描述"
actionLabel="自定义按钮文本"
iconName="person-circle-outline"
gradientColors={['#FF6B6B', '#FF8E53']}
onLoginPress={() => {
// 自定义登录处理逻辑
}}
/>
```
### 渐变色彩搭配
```typescript
// 蓝色系
gradientColors={['#007AFF', '#0056D2']}
// 紫色系
gradientColors={['#A8B8FF', '#7B68EE']}
// 绿色系
gradientColors={['#00C9A7', '#00A884']}
// 橙色系
gradientColors={['#FFA07A', '#FF6347']}
```
## 📝 迁移指南
### 从重定向迁移到Modal
**旧方式(重定向):**
```typescript
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
```
**新方式Modal:**
```typescript
const { requireAuth } = useAuthGuard();
if (!isAuthenticated) {
return (
<AuthPrompt
variant="fullscreen"
title="需要登录"
subtitle="登录后即可使用所有功能"
/>
);
}
```
### 使用装饰器模式保护函数
**旧方式:**
```typescript
const handleAction = async () => {
if (!isAuthenticated) {
router.push('/(auth)/login');
return;
}
// 执行操作
};
```
**新方式:**
```typescript
const handleAction = async () => {
requireAuth(() => {
// 执行操作
});
};
```
## 🐛 常见问题
### Q: 如何处理登录失败?
A: LoginModal已经内置了错误处理登录失败会显示Alert提示。可以在`onLoginPress`回调中自定义处理逻辑。
### Q: 如何自定义登录流程?
A: 可以使用`onLoginPress`属性自定义登录处理,或者直接使用`requireAuth()`函数。
### Q: Token如何自动刷新
A: TokenManager提供了自动刷新机制会在Token即将过期时自动刷新。目前需要实现refreshToken逻辑。
### Q: 如何处理多个认证检查?
A: 每个`requireAuth`调用都会排队执行,登录成功后会依次执行所有回调。
## 📦 相关文件
- `components/auth/auth-provider.tsx` - 全局认证上下文
- `hooks/use-auth-guard.ts` - 认证拦截Hook
- `components/auth/auth-guard.tsx` - 认证守卫组件
- `components/auth/auth-prompt.tsx` - 用户引导组件
- `components/auth/login-modal.tsx` - 登录Modal
- `lib/auth/token-manager.ts` - Token管理器
- `lib/auth/secure-client.ts` - 安全API客户端
- `lib/api/client.ts` - API客户端已更新
## 🎯 下一步
1. 实现完整的Token刷新逻辑
2. 添加认证状态缓存
3. 集成更多登录方式SSO、指纹等
4. 添加登录统计和分析

View File

@@ -1,229 +0,0 @@
# 🔐 认证系统升级完成总结
## ✅ 已完成的所有任务
### 1. **核心架构组件**
-`AuthProvider` - 全局认证上下文提供者管理登录状态和Modal显示
-`useAuthGuard` - 认证拦截Hook提供`requireAuth`方法
-`AuthGuard` - 认证守卫组件,包装需要认证的内容
-`AuthPrompt` - 用户引导组件,提供多种样式的登录提示
### 2. **登录界面优化**
-`LoginModal` - 已优化为底部抽屉式设计,体验流畅
- ✅ 自动登录状态检测和Modal开关控制
- ✅ 登录成功后自动关闭并恢复用户操作
### 3. **页面架构调整**
- ✅ 根布局 (`app/_layout.tsx`) - 集成`AuthProvider`
- ✅ Tabs布局 (`app/(tabs)/_layout.tsx`) - 移除重定向逻辑
- ✅ Explore页面 (`app/(tabs)/explore.tsx`) - 添加认证保护
- ✅ Profile页面 - 已有认证支持(使用`useAuth`
### 4. **API安全增强**
-`token-manager.ts` - Token生命周期管理和自动刷新
-`secure-client.ts` - 安全API客户端自动处理认证
-`lib/api/client.ts` - 更新支持认证头和错误处理
### 5. **文档和指南**
-`AUTH_SYSTEM.md` - 完整的认证系统使用指南
-`AUTH_UPGRADE_SUMMARY.md` - 本总结文档
## 📁 新增文件列表
```
components/auth/
├── auth-provider.tsx [新增] 全局认证上下文
├── auth-guard.tsx [新增] 认证守卫组件
├── auth-prompt.tsx [新增] 用户引导组件
└── login-modal.tsx [已存在] 底部抽屉式登录Modal
hooks/
└── use-auth-guard.ts [新增] 认证拦截Hook
lib/auth/
├── token-manager.ts [新增] Token管理器
└── secure-client.ts [新增] 安全API客户端
docs/
├── AUTH_SYSTEM.md [新增] 使用指南
└── AUTH_UPGRADE_SUMMARY.md [新增] 升级总结
已修改文件:
- app/_layout.tsx 集成AuthProvider
- app/(tabs)/_layout.tsx 移除重定向
- app/(tabs)/explore.tsx 添加认证保护
- lib/api/client.ts 增强认证支持
```
## 🎯 升级前后的对比
### 升级前(重定向模式)
```typescript
// 问题:生硬的页面跳转
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
```
### 升级后Modal模式
```typescript
// 优势:流畅的原生体验
const { requireAuth } = useAuthGuard();
const handleAction = () => {
requireAuth(() => {
// 登录后自动执行
executeFeature();
});
};
```
## 🚀 核心特性
### 1. **智能认证拦截**
- 检测到需要登录的操作时自动弹出底部Modal
- 登录成功后立即恢复用户操作
- 支持队列机制,多个认证检查会依次执行
### 2. **灵活的组件系统**
- **AuthGuard**: 包装需要认证的组件
- **AuthPrompt**: 提供三种样式fullscreen/card/inline
- **useAuthGuard**: 灵活的函数级认证
### 3. **完整的Token管理**
- 自动Token刷新可配置阈值
- Token过期自动清理
- 支持refresh token机制
### 4. **API安全增强**
- 自动添加认证头部
- 401错误自动处理
- 请求重试机制
## 📊 使用示例
### 示例1页面级保护
```typescript
function ProtectedPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return (
<AuthPrompt
variant="fullscreen"
title="需要登录"
subtitle="登录后即可访问此页面"
/>
);
}
return <PageContent />;
}
```
### 示例2功能级保护
```typescript
function FeatureButton() {
const { requireAuth } = useAuthGuard();
const handleClick = () => {
requireAuth(() => {
// 执行需要登录的功能
executeFeature();
});
};
return <Button title="使用功能" onPress={handleClick} />;
}
```
### 示例3API请求
```typescript
// 带认证的API请求
const data = await apiClient('/api/user/profile');
// 或指定requiresAuth参数
const data = await apiRequest('/api/data', {
requiresAuth: true,
});
```
## 🔧 技术亮点
1. **TypeScript完整支持**
- 所有组件和函数都有完整的类型定义
- 编译时类型检查,确保代码安全
2. **React最佳实践**
- 使用Context API管理全局状态
- 合理使用useCallback和useEffect优化性能
- 遵循React Hooks规范
3. **优雅的错误处理**
- 登录失败显示友好提示
- API错误自动处理和重试
- Token过期自动清理
4. **优秀的用户体验**
- 流畅的动画效果
- 即时反馈
- 无页面跳转的原生体验
## 🎨 UI/UX改进
### 登录Modal设计
- 底部抽屉式,符合原生设计规范
- 渐变背景和图标
- 流畅的滑入滑出动画
- 背景点击关闭
### 认证提示样式
- **fullscreen**: 全屏居中展示
- **card**: 卡片样式,视觉层次清晰
- **inline**: 行内样式,紧凑布局
## 🔮 未来优化方向
1. **Token刷新逻辑完善**
- 实现完整的refresh token机制
- 添加Token提前刷新策略
2. **更多登录方式**
- 集成SSO登录
- 支持指纹/Face ID
- 第三方登录Google/Apple
3. **认证状态缓存**
- 本地缓存认证状态
- 减少页面刷新时的闪烁
4. **性能优化**
- 认证状态持久化
- 懒加载认证组件
## 📝 开发者指南
### 安装和配置
认证系统已经集成到项目中,无需额外安装。
### 使用步骤
1. 认证Provider已在根布局中启用
2. 在需要的地方使用`useAuthGuard``AuthGuard`组件
3. API请求使用`apiClient``apiRequest`
### 调试
- 查看控制台日志获取认证状态信息
- 使用`tokenManager`检查Token状态
- 通过`pendingCallback`跟踪登录流程
## 🎉 总结
此次升级成功实现了从页面重定向到底部Modal的平滑过渡提供了
-**更流畅的用户体验** - 无页面跳转,原生交互
- 🛡️ **更强的安全性** - 自动Token管理和刷新
- 🎨 **更灵活的组件** - 多种认证组件满足不同场景
- 📚 **更完善的文档** - 详细的使用指南和示例
- 🔧 **更好的开发者体验** - TypeScript支持和最佳实践
认证系统现已准备就绪,可以为用户提供安全、流畅的登录体验!

View File

@@ -1,5 +1,5 @@
import { fetch, FetchRequestInit } from 'expo/fetch';
import { tokenManager, getAuthHeaders } from '../auth/token-manager';
import { getAuthHeaders, tokenManager } from '../auth/token-manager';
const BASE_URL = 'https://api-test.mixvideo.bowong.cc';

20
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"dependencies": {
"@better-auth/expo": "^1.3.27",
"@better-auth/stripe": "^1.3.27",
"@bowong/better-auth-stripe": "1.3.27-c",
"@bowong/better-auth-stripe": "1.3.27-g",
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
@@ -37,6 +37,7 @@
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.8",
"lucide-react": "^0.548.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",
@@ -1508,9 +1509,9 @@
"version": "1.1.18"
},
"node_modules/@bowong/better-auth-stripe": {
"version": "1.3.27-c",
"resolved": "https://registry.npmmirror.com/@bowong/better-auth-stripe/-/better-auth-stripe-1.3.27-c.tgz",
"integrity": "sha512-2rW/YWKCzWun0bxlGhu+rZj6xwuXpr6dO7yf4YNBkRQyLEFHzj++oL6G9jANF5aMXe9RaD64wT2SaPWnhEs0Wg==",
"version": "1.3.27-g",
"resolved": "https://registry.npmjs.org/@bowong/better-auth-stripe/-/better-auth-stripe-1.3.27-g.tgz",
"integrity": "sha512-FlbTF5U1qegVvrDOpwVzQshLla5OuPLQFrNwLJ1QbWf5sesq1iO9bBi2zukhEmEsmj3gdt4qBS+nFa2FyqYTmA==",
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -4339,7 +4340,7 @@
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
@@ -11471,6 +11472,15 @@
"version": "3.1.1",
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.548.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz",
"integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz",

View File

@@ -42,6 +42,7 @@
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.8",
"lucide-react": "^0.548.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",