Initial commit: expo-popcore project
This commit is contained in:
676
app/result.tsx
Normal file
676
app/result.tsx
Normal file
@@ -0,0 +1,676 @@
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { getTemplateGeneration } from '@/lib/api/template-runs';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { documentDirectory, downloadAsync } from 'expo-file-system/legacy';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Download,
|
||||
Maximize2,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Image, Modal, Platform, ScrollView,
|
||||
StyleSheet, Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// ResizeMode 兼容映射 - 移除 'stretch',expo-video 不支持
|
||||
const ResizeMode = {
|
||||
CONTAIN: 'contain' as const,
|
||||
COVER: 'cover' as const,
|
||||
STRETCH: 'fill' as const, // 使用 'fill' 替代 'stretch'
|
||||
};
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
const HERO_HEIGHT = screenWidth * 0.58;
|
||||
const VIDEO_HEIGHT = screenHeight * 0.6;
|
||||
|
||||
// 原生视频播放组件
|
||||
const NativeVideoPlayer: React.FC<{ url: string; style?: any }> = ({ url, style }) => {
|
||||
const player = useVideoPlayer(url, player => {
|
||||
player.loop = false;
|
||||
});
|
||||
|
||||
return (
|
||||
<VideoView
|
||||
player={player}
|
||||
style={style}
|
||||
contentFit="contain"
|
||||
allowsFullscreen
|
||||
allowsPictureInPicture
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResultWithTemplate {
|
||||
id: string;
|
||||
userId: string;
|
||||
templateId: string;
|
||||
type: 'UNKNOWN' | 'TEXT' | 'IMAGE' | 'VIDEO';
|
||||
resultUrl: string[];
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
creditsCost?: number;
|
||||
creditsTransactionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
template?: {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件 URL 后缀名判断媒体类型
|
||||
*/
|
||||
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||
|
||||
if (imageExts.includes(extension)) return 'image';
|
||||
if (videoExts.includes(extension)) return 'video';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取媒体类型,过滤掉 unknown
|
||||
*/
|
||||
function getSafeMediaType(url: string): 'image' | 'video' {
|
||||
const type = getMediaType(url);
|
||||
return type === 'unknown' ? 'image' : type;
|
||||
}
|
||||
|
||||
export default function ResultPage() {
|
||||
const { generationId } = useLocalSearchParams<{ generationId: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [result, setResult] = useState<ResultWithTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fullscreenVisible, setFullscreenVisible] = useState(false);
|
||||
const [fullscreenContent, setFullscreenContent] = useState<{
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (generationId) {
|
||||
loadResult();
|
||||
}
|
||||
}, [generationId]);
|
||||
|
||||
const loadResult = async () => {
|
||||
if (!generationId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getTemplateGeneration(generationId);
|
||||
|
||||
if (response?.success && response.data) {
|
||||
setResult(response.data as any);
|
||||
} else {
|
||||
Alert.alert('错误', '无法加载结果详情');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load result:', error);
|
||||
Alert.alert('错误', '加载结果失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRerun = () => {
|
||||
router.push(`/templates/${result?.templateId}/form`);
|
||||
};
|
||||
|
||||
const handleSaveToGallery = async () => {
|
||||
if (!result || result.type === 'TEXT') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Web 平台处理
|
||||
if (Platform.OS === 'web') {
|
||||
for (const url of result.resultUrl) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
|
||||
// 类型安全的处理方式
|
||||
if (typeof window !== 'undefined' && window.URL && window.URL.createObjectURL) {
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = url.split('/').pop() || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
// 添加延迟,避免浏览器阻止多个下载
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
} catch (error) {
|
||||
console.error('Web download failed:', url, error);
|
||||
}
|
||||
}
|
||||
|
||||
const count = result.resultUrl.length;
|
||||
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
|
||||
Alert.alert(
|
||||
'下载开始',
|
||||
count > 1 ? `正在下载${count}个${mediaType}` : `正在下载${mediaType}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 原生平台处理
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量下载所有媒体文件
|
||||
let successCount = 0;
|
||||
for (const url of result.resultUrl) {
|
||||
try {
|
||||
// 生成临时文件名
|
||||
const filename = url.split('/').pop() || `download_${Date.now()}`;
|
||||
|
||||
if (!documentDirectory) {
|
||||
console.error('Document directory not available');
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileUri = `${documentDirectory}${filename}`;
|
||||
|
||||
// 下载文件到本地
|
||||
const downloadResult = await downloadAsync(url, fileUri);
|
||||
|
||||
if (downloadResult.status === 200) {
|
||||
// 保存到相册
|
||||
const asset = await MediaLibrary.createAssetAsync(downloadResult.uri);
|
||||
successCount++;
|
||||
} else {
|
||||
console.error('Download failed with status:', downloadResult.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download and save:', url, error);
|
||||
}
|
||||
}
|
||||
|
||||
const count = result.resultUrl.length;
|
||||
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
|
||||
|
||||
if (successCount === count) {
|
||||
Alert.alert(
|
||||
'保存成功',
|
||||
count > 1 ? `${count}个${mediaType}已保存到相册` : `${mediaType}已保存到相册`
|
||||
);
|
||||
} else if (successCount > 0) {
|
||||
Alert.alert(
|
||||
'部分保存成功',
|
||||
`${successCount}/${count}个${mediaType}已保存到相册`
|
||||
);
|
||||
} else {
|
||||
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save to gallery error:', error);
|
||||
Alert.alert('保存失败', '无法保存文件,请稍后重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = async () => {
|
||||
if (!result || result.type !== 'TEXT') return;
|
||||
|
||||
try {
|
||||
await Clipboard.setStringAsync(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();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#D1FF00" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>未找到结果</Text>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextResult = result.type === 'TEXT';
|
||||
const mediaUrls = result.resultUrl || [];
|
||||
const hasMultipleMedia = mediaUrls.length > 1;
|
||||
|
||||
const renderMediaItem = (url: string, index: number) => {
|
||||
const mediaType = getMediaType(url);
|
||||
|
||||
return (
|
||||
<View key={`media-${index}`} style={{ width: screenWidth, backgroundColor: '#000' }}>
|
||||
{mediaType === 'video' ? (
|
||||
Platform.OS === 'web' ? (
|
||||
<video
|
||||
src={url}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: VIDEO_HEIGHT,
|
||||
objectFit: 'contain' as any,
|
||||
}}
|
||||
controls
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<NativeVideoPlayer
|
||||
url={url}
|
||||
style={{ width: '100%', height: VIDEO_HEIGHT }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={{ width: '100%', height: VIDEO_HEIGHT }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const handleScroll = (event: any) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
const index = Math.round(offsetX / screenWidth);
|
||||
setCurrentMediaIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={[styles.safeGuard, { paddingTop: insets.top || 16 }]} />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.contentPadding}>
|
||||
<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}>
|
||||
{result.template?.title || '生成结果'}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{result.template?.titleEn || ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isTextResult ? (
|
||||
<View style={styles.heroCard}>
|
||||
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.generatedText}>{mediaUrls[0]}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.mediaContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
>
|
||||
{mediaUrls.map((url, index) => renderMediaItem(url, index))}
|
||||
</ScrollView>
|
||||
|
||||
{hasMultipleMedia && (
|
||||
<View style={styles.pagination}>
|
||||
{mediaUrls.map((_, index) => (
|
||||
<View
|
||||
key={`dot-${index}`}
|
||||
style={[
|
||||
styles.paginationDot,
|
||||
index === currentMediaIndex && styles.paginationDotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.expandButton}
|
||||
onPress={() =>
|
||||
openFullscreen(
|
||||
getSafeMediaType(mediaUrls[currentMediaIndex]),
|
||||
mediaUrls[currentMediaIndex]
|
||||
)
|
||||
}
|
||||
>
|
||||
<Maximize2 size={18} color="#F5F5F7" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</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"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{Platform.OS === 'web' ? (
|
||||
<video
|
||||
src={fullscreenContent.url}
|
||||
style={styles.fullscreenMediaContent as any}
|
||||
autoPlay
|
||||
controls
|
||||
/>
|
||||
) : (
|
||||
<NativeVideoPlayer
|
||||
url={fullscreenContent.url}
|
||||
style={styles.fullscreenMediaContent}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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: {
|
||||
paddingBottom: 160,
|
||||
},
|
||||
contentPadding: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
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,
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#131317',
|
||||
padding: 24,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mediaContainer: {
|
||||
marginTop: 32,
|
||||
position: 'relative',
|
||||
backgroundColor: '#09090B',
|
||||
},
|
||||
carouselContent: {
|
||||
gap: 0,
|
||||
},
|
||||
pagination: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
paginationDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
paginationDotActive: {
|
||||
backgroundColor: '#D9FF3F',
|
||||
width: 24,
|
||||
},
|
||||
expandButton: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
textScroll: {
|
||||
maxHeight: HERO_HEIGHT,
|
||||
},
|
||||
generatedText: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user