694 lines
18 KiB
TypeScript
694 lines
18 KiB
TypeScript
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);
|
||
} 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 () => {
|
||
console.log('handleSaveToGallery called');
|
||
if (!result || result.type === 'TEXT') {
|
||
console.log('Skipping: no result or text type');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSaving(true);
|
||
console.log('Platform:', Platform.OS);
|
||
console.log('URLs to download:', result.resultUrl);
|
||
|
||
// Web 平台处理
|
||
if (Platform.OS === 'web') {
|
||
console.log('Using web download method');
|
||
for (const url of result.resultUrl) {
|
||
try {
|
||
console.log('Downloading:', url);
|
||
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);
|
||
console.log('Download triggered for:', url);
|
||
}
|
||
|
||
// 添加延迟,避免浏览器阻止多个下载
|
||
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;
|
||
}
|
||
|
||
// 原生平台处理
|
||
console.log('Using native download method');
|
||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||
console.log('Permission status:', status);
|
||
|
||
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}`;
|
||
|
||
// 下载文件到本地
|
||
console.log('Downloading:', url, 'to', fileUri);
|
||
const downloadResult = await downloadAsync(url, fileUri);
|
||
|
||
if (downloadResult.status === 200) {
|
||
// 保存到相册
|
||
const asset = await MediaLibrary.createAssetAsync(downloadResult.uri);
|
||
console.log('Saved to library:', asset.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);
|
||
console.log('handleSaveToGallery completed');
|
||
}
|
||
};
|
||
|
||
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 = () => {
|
||
console.log('Download button pressed');
|
||
if (!result) {
|
||
console.log('No result available');
|
||
return;
|
||
}
|
||
|
||
console.log('Result type:', result.type);
|
||
console.log('Result URLs:', result.resultUrl);
|
||
|
||
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,
|
||
},
|
||
});
|