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