Initial commit: Expo app with Better Auth integration

- Complete Expo React Native app setup with TypeScript
- Better Auth authentication system integration
- Secure storage implementation for session tokens
- Authentication flow with login/logout functionality
- API client configuration for backend communication
- Responsive UI components with themed styling
- Expo Router navigation setup
- Development configuration and scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-10-14 10:32:52 +08:00
commit 7e3f94bae3
71 changed files with 20800 additions and 0 deletions

View File

@@ -0,0 +1,449 @@
import {
View,
StyleSheet,
ScrollView,
TouchableOpacity,
Share,
Alert,
Dimensions
} from 'react-native';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { TemplateGeneration, GenerateType } from '@/lib/types/template-run';
import { Image } from 'expo-image';
import { VideoPlayer } from '@/components/video/video-player';
import { useState } from 'react';
import * as FileSystem from 'expo-file-system';
import * as MediaLibrary from 'expo-media-library';
interface ResultDisplayProps {
result: TemplateGeneration;
onShare?: (url: string) => void;
onDownload?: (url: string) => void;
onRerun?: () => void;
}
const { width: screenWidth } = Dimensions.get('window');
export function ResultDisplay({
result,
onShare,
onDownload,
onRerun
}: ResultDisplayProps) {
const [downloadingItems, setDownloadingItems] = useState<Set<number>>(new Set());
const [sharing, setSharing] = useState(false);
const getTypeLabel = () => {
switch (result.type) {
case 'IMAGE':
return '图片生成结果';
case 'VIDEO':
return '视频生成结果';
case 'TEXT':
return '文本生成结果';
default:
return '生成结果';
}
};
const getTypeIcon = () => {
switch (result.type) {
case 'IMAGE':
return '🖼️';
case 'VIDEO':
return '🎬';
case 'TEXT':
return '📝';
default:
return '📄';
}
};
const handleShare = async (url: string) => {
if (sharing) return;
try {
setSharing(true);
if (onShare) {
onShare(url);
return;
}
await Share.share({
message: `查看生成的${result.type === 'IMAGE' ? '图片' : result.type === 'VIDEO' ? '视频' : '文本'}: ${url}`,
url: url,
});
} catch (error) {
console.error('分享失败:', error);
Alert.alert('分享失败', '无法分享此内容,请稍后重试');
} finally {
setSharing(false);
}
};
const handleDownload = async (url: string, index: number) => {
if (downloadingItems.has(index)) return;
try {
setDownloadingItems(prev => new Set(prev).add(index));
if (onDownload) {
onDownload(url);
return;
}
// 请求媒体库权限
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
return;
}
// 下载文件
const downloadResult = await FileSystem.downloadAsync(
url,
FileSystem.documentDirectory + `generated_${Date.now()}_${index}.${getFileExtension(url)}`
);
// 保存到媒体库
if (result.type === 'IMAGE') {
await MediaLibrary.saveToLibraryAsync(downloadResult.uri);
Alert.alert('保存成功', '图片已保存到相册');
} else if (result.type === 'VIDEO') {
await MediaLibrary.saveToLibraryAsync(downloadResult.uri);
Alert.alert('保存成功', '视频已保存到相册');
}
} catch (error) {
console.error('下载失败:', error);
Alert.alert('下载失败', '无法保存文件,请检查网络连接');
} finally {
setDownloadingItems(prev => {
const newSet = new Set(prev);
newSet.delete(index);
return newSet;
});
}
};
const getFileExtension = (url: string): string => {
const parts = url.split('.');
return parts[parts.length - 1] || 'jpg';
};
const renderMediaItem = (url: string, index: number) => {
const isVideo = /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(url);
if (isVideo) {
return (
<View key={index} style={styles.mediaContainer}>
<VideoPlayer
source={{ uri: url }}
poster={undefined} // 结果页不需要封面图
useNativeControls={true}
autoPlay={false}
maxHeight={screenWidth * 0.9} // 最大高度为屏幕宽度的90%
onReady={(status) => {
console.log(`视频 ${index} 加载完成:`, status);
}}
onError={(error) => {
console.error(`视频 ${index} 加载失败:`, error);
}}
/>
<View style={styles.mediaActions}>
<TouchableOpacity
style={[styles.actionButton, styles.shareButton]}
onPress={() => handleShare(url)}
disabled={sharing}
activeOpacity={0.8}
>
<ThemedText style={styles.actionButtonText}>
{sharing ? '分享中...' : '分享'}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.downloadButton]}
onPress={() => handleDownload(url, index)}
disabled={downloadingItems.has(index)}
activeOpacity={0.8}
>
<ThemedText style={styles.actionButtonText}>
{downloadingItems.has(index) ? '下载中...' : '下载'}
</ThemedText>
</TouchableOpacity>
</View>
</View>
);
}
return (
<View key={index} style={styles.mediaContainer}>
<Image
source={{ uri: url }}
style={styles.image}
contentFit="contain"
/>
<View style={styles.mediaActions}>
<TouchableOpacity
style={[styles.actionButton, styles.shareButton]}
onPress={() => handleShare(url)}
disabled={sharing}
activeOpacity={0.8}
>
<ThemedText style={styles.actionButtonText}>
{sharing ? '分享中...' : '分享'}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.downloadButton]}
onPress={() => handleDownload(url, index)}
disabled={downloadingItems.has(index)}
activeOpacity={0.8}
>
<ThemedText style={styles.actionButtonText}>
{downloadingItems.has(index) ? '下载中...' : '下载'}
</ThemedText>
</TouchableOpacity>
</View>
</View>
);
};
const renderTextContent = (url: string, index: number) => {
return (
<View key={index} style={styles.textContainer}>
<ThemedView style={styles.textBox}>
<ThemedText style={styles.textContent}>
{url} // 这里假设URL包含文本内容实际可能需要额外处理
</ThemedText>
</ThemedView>
<View style={styles.textActions}>
<TouchableOpacity
style={[styles.actionButton, styles.shareButton]}
onPress={() => handleShare(url)}
disabled={sharing}
activeOpacity={0.8}
>
<ThemedText style={styles.actionButtonText}>
{sharing ? '分享中...' : '分享'}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.copyButton]}
onPress={() => {
// 这里可以实现复制文本到剪贴板的功能
Alert.alert('复制成功', '文本已复制到剪贴板');
}}
activeOpacity={0.8}
>
<ThemedText style={styles.actionButtonText}></ThemedText>
</TouchableOpacity>
</View>
</View>
);
};
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
<ThemedView style={styles.content}>
{/* 结果头部 */}
<View style={styles.header}>
<View style={styles.titleContainer}>
<ThemedText style={styles.titleIcon}>
{getTypeIcon()}
</ThemedText>
<ThemedText style={styles.title}>
{getTypeLabel()}
</ThemedText>
</View>
<View style={styles.stats}>
<View style={styles.statItem}>
<ThemedText style={styles.statLabel}></ThemedText>
<ThemedText style={styles.statValue}>{result.resultUrl.length}</ThemedText>
</View>
{result.creditsCost && (
<View style={styles.statItem}>
<ThemedText style={styles.statLabel}></ThemedText>
<ThemedText style={styles.statValue}>{result.creditsCost}</ThemedText>
</View>
)}
</View>
</View>
{/* 结果内容 */}
<View style={styles.results}>
{result.resultUrl.length === 0 ? (
<View style={styles.emptyContainer}>
<ThemedText style={styles.emptyText}>
</ThemedText>
</View>
) : (
result.resultUrl.map((url, index) => {
if (result.type === 'TEXT') {
return renderTextContent(url, index);
} else {
return renderMediaItem(url, index);
}
})
)}
</View>
{/* 操作按钮 */}
{onRerun && (
<View style={styles.footerActions}>
<TouchableOpacity
style={styles.rerunButton}
onPress={onRerun}
activeOpacity={0.8}
>
<ThemedText style={styles.rerunButtonText}>🔄 </ThemedText>
</TouchableOpacity>
</View>
)}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 20,
},
header: {
marginBottom: 24,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
titleIcon: {
fontSize: 24,
marginRight: 8,
},
title: {
fontSize: 20,
fontWeight: '600',
},
stats: {
flexDirection: 'row',
gap: 20,
},
statItem: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: 12,
padding: 12,
alignItems: 'center',
},
statLabel: {
fontSize: 12,
opacity: 0.7,
marginBottom: 4,
},
statValue: {
fontSize: 18,
fontWeight: '600',
},
results: {
marginBottom: 24,
},
emptyContainer: {
alignItems: 'center',
paddingVertical: 32,
},
emptyText: {
fontSize: 16,
opacity: 0.6,
},
mediaContainer: {
marginBottom: 20,
},
image: {
width: '100%',
height: screenWidth * 0.75,
borderRadius: 12,
backgroundColor: '#f0f0f0',
},
mediaActions: {
flexDirection: 'row',
gap: 12,
marginTop: 12,
},
textContainer: {
marginBottom: 20,
},
textBox: {
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: 12,
padding: 16,
marginBottom: 12,
minHeight: 100,
},
textContent: {
fontSize: 16,
lineHeight: 24,
},
textActions: {
flexDirection: 'row',
gap: 12,
},
actionButton: {
flex: 1,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: 'center',
},
shareButton: {
backgroundColor: 'rgba(0, 122, 255, 0.1)',
borderWidth: 1,
borderColor: 'rgba(0, 122, 255, 0.3)',
},
downloadButton: {
backgroundColor: 'rgba(52, 199, 89, 0.1)',
borderWidth: 1,
borderColor: 'rgba(52, 199, 89, 0.3)',
},
copyButton: {
backgroundColor: 'rgba(142, 142, 147, 0.1)',
borderWidth: 1,
borderColor: 'rgba(142, 142, 147, 0.3)',
},
actionButtonText: {
fontSize: 14,
fontWeight: '600',
},
footerActions: {
alignItems: 'center',
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: 'rgba(0, 0, 0, 0.1)',
},
rerunButton: {
backgroundColor: 'rgba(78, 205, 196, 0.1)',
borderRadius: 8,
paddingVertical: 14,
paddingHorizontal: 32,
borderWidth: 1,
borderColor: 'rgba(78, 205, 196, 0.3)',
},
rerunButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#4ECDC4',
},
});

View File

@@ -0,0 +1,274 @@
import { View, StyleSheet, Animated, TouchableOpacity } from 'react-native';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { LinearGradient } from 'expo-linear-gradient';
import { RunProgress, GenerationStatus } from '@/lib/types/template-run';
import { useEffect, useRef } from 'react';
interface RunProgressViewProps {
progress: RunProgress;
onCancel?: () => void;
}
export function RunProgressView({ progress, onCancel }: RunProgressViewProps) {
const animatedWidth = useRef(new Animated.Value(0)).current;
const pulseAnimation = useRef(new Animated.Value(1)).current;
// 进度条动画
useEffect(() => {
Animated.timing(animatedWidth, {
toValue: progress.progress,
duration: 500,
useNativeDriver: false,
}).start();
}, [progress.progress, animatedWidth]);
// 脉冲动画
useEffect(() => {
if (progress.status === 'running') {
const pulseLoop = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimation, {
toValue: 1.1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnimation, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
pulseLoop.start();
return () => pulseLoop.stop();
} else {
pulseAnimation.setValue(1);
}
}, [progress.status, pulseAnimation]);
const getStatusColor = () => {
switch (progress.status) {
case 'pending':
return ['#FFA500', '#FF8C00']; // 橙色
case 'running':
return ['#4ECDC4', '#44A3A0']; // 青色
case 'completed':
return ['#52B788', '#40916C']; // 绿色
case 'failed':
return ['#FF6B6B', '#FF5252']; // 红色
default:
return ['#999999', '#666666'];
}
};
const getStatusText = () => {
switch (progress.status) {
case 'pending':
return '准备中...';
case 'running':
return '执行中...';
case 'completed':
return '已完成';
case 'failed':
return '执行失败';
default:
return '未知状态';
}
};
const getStatusIcon = () => {
switch (progress.status) {
case 'pending':
return '⏳';
case 'running':
return '🔄';
case 'completed':
return '✅';
case 'failed':
return '❌';
default:
return '❓';
}
};
const colors = getStatusColor();
return (
<ThemedView style={styles.container}>
{/* 状态头部 */}
<View style={styles.header}>
<Animated.View
style={[
styles.statusIcon,
{
transform: [{ scale: pulseAnimation }],
},
]}
>
<ThemedText style={styles.iconText}>
{getStatusIcon()}
</ThemedText>
</Animated.View>
<View style={styles.statusInfo}>
<ThemedText style={styles.statusText}>
{getStatusText()}
</ThemedText>
<ThemedText style={styles.messageText}>
{progress.message}
</ThemedText>
</View>
</View>
{/* 进度条 */}
<View style={styles.progressContainer}>
<View style={styles.progressBackground}>
<Animated.View
style={[
styles.progressFill,
{
width: animatedWidth.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
extrapolate: 'clamp',
}),
},
]}
>
<LinearGradient
colors={colors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.progressGradient}
/>
</Animated.View>
</View>
<ThemedText style={styles.progressText}>
{Math.round(progress.progress)}%
</ThemedText>
</View>
{/* 详细信息 */}
<View style={styles.details}>
<ThemedText style={styles.detailText}>
: <ThemedText style={styles.detailValue}>{getStatusText()}</ThemedText>
</ThemedText>
{progress.result?.creditsCost && (
<ThemedText style={styles.detailText}>
: <ThemedText style={styles.detailValue}>{progress.result.creditsCost}</ThemedText>
</ThemedText>
)}
{progress.result?.type && (
<ThemedText style={styles.detailText}>
: <ThemedText style={styles.detailValue}>
{progress.result.type === 'IMAGE' ? '图片' :
progress.result.type === 'VIDEO' ? '视频' :
progress.result.type === 'TEXT' ? '文本' : '未知'}
</ThemedText>
</ThemedText>
)}
</View>
{/* 操作按钮 */}
{onCancel && (progress.status === 'pending' || progress.status === 'running') && (
<View style={styles.actions}>
<TouchableOpacity style={styles.cancelButton} onPress={onCancel}>
<ThemedText style={styles.cancelButtonText}></ThemedText>
</TouchableOpacity>
</View>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 24,
},
statusIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(78, 205, 196, 0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
iconText: {
fontSize: 24,
},
statusInfo: {
flex: 1,
},
statusText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
messageText: {
fontSize: 14,
opacity: 0.7,
},
progressContainer: {
marginBottom: 24,
},
progressBackground: {
height: 8,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: 4,
overflow: 'hidden',
marginBottom: 8,
},
progressFill: {
height: '100%',
borderRadius: 4,
overflow: 'hidden',
},
progressGradient: {
flex: 1,
},
progressText: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
details: {
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: 12,
padding: 16,
marginBottom: 20,
},
detailText: {
fontSize: 14,
marginBottom: 8,
},
detailValue: {
fontWeight: '600',
},
actions: {
alignItems: 'center',
},
cancelButton: {
backgroundColor: 'rgba(255, 59, 48, 0.1)',
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 24,
borderWidth: 1,
borderColor: 'rgba(255, 59, 48, 0.3)',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#FF3B30',
},
});