502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
Dimensions,
|
|
Pressable,
|
|
StatusBar as RNStatusBar,
|
|
Alert,
|
|
ScrollView,
|
|
} from 'react-native'
|
|
import { StatusBar } from 'expo-status-bar'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { Image } from 'expo-image'
|
|
import { useRouter, useLocalSearchParams } from 'expo-router'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
|
|
import { LeftArrowIcon, DeleteIcon, EditIcon, ChangeIcon, WhiteStarIcon } from '@/components/icon'
|
|
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'
|
|
import LoadingState from '@/components/LoadingState'
|
|
import ErrorState from '@/components/ErrorState'
|
|
import { VideoPlayer } from '@/components/ui/video'
|
|
import {
|
|
useGenerationDetail,
|
|
useDeleteGeneration,
|
|
useRerunGeneration,
|
|
useDownloadMedia,
|
|
} from '@/hooks'
|
|
|
|
const { width: screenWidth } = Dimensions.get('window')
|
|
|
|
export default function GenerationRecordScreen() {
|
|
const { t } = useTranslation()
|
|
const router = useRouter()
|
|
const params = useLocalSearchParams()
|
|
const generationId = params.id as string
|
|
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
|
|
const { data: generation, loading, error, execute } = useGenerationDetail()
|
|
const { deleteGeneration, loading: deleting } = useDeleteGeneration()
|
|
const { rerun, loading: rerunning } = useRerunGeneration()
|
|
const { download, loading: downloading, progress } = useDownloadMedia()
|
|
|
|
useEffect(() => {
|
|
if (generationId) {
|
|
execute({ id: generationId })
|
|
}
|
|
}, [generationId, execute])
|
|
|
|
const handleDelete = async () => {
|
|
if (!generation) return
|
|
|
|
const { error } = await deleteGeneration(generation.id)
|
|
|
|
if (error) {
|
|
Alert.alert(t('common.error'), error.message || t('generationRecord.deleteError'))
|
|
} else {
|
|
Alert.alert(t('common.success'), t('generationRecord.deleteSuccess'))
|
|
router.back()
|
|
}
|
|
|
|
setDeleteDialogOpen(false)
|
|
}
|
|
|
|
const handleRerun = async () => {
|
|
if (!generation) return
|
|
|
|
const { generationId: newGenerationId, error } = await rerun(generation.id)
|
|
|
|
if (error) {
|
|
Alert.alert(t('common.error'), error.message || t('generationRecord.rerunError'))
|
|
} else if (newGenerationId) {
|
|
Alert.alert(t('common.success'), t('generationRecord.rerunSuccess'))
|
|
// 跳转到新的生成记录详情页
|
|
router.replace({
|
|
pathname: '/generationRecord',
|
|
params: { id: newGenerationId },
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleTryAgain = () => {
|
|
if (!generation?.template) return
|
|
|
|
router.push({
|
|
pathname: '/generateVideo' as any,
|
|
params: { templateId: generation.template.id },
|
|
})
|
|
}
|
|
|
|
const handleDownload = async () => {
|
|
if (!generation?.resultUrl?.[0]) return
|
|
|
|
const url = generation.resultUrl[0]
|
|
const mediaType = generation.type === 'VIDEO' ? 'video' : 'image'
|
|
|
|
const { success, error } = await download(url, mediaType)
|
|
|
|
if (success) {
|
|
Alert.alert(t('common.success'), t('generationRecord.downloadSuccess'))
|
|
} else {
|
|
const errorMessage = error === 'Permission denied'
|
|
? t('generationRecord.permissionDenied')
|
|
: t('generationRecord.downloadError')
|
|
Alert.alert(t('common.error'), errorMessage)
|
|
}
|
|
}
|
|
|
|
const getStatusText = (status: string) => {
|
|
switch (status?.toLowerCase()) {
|
|
case 'completed':
|
|
case 'success':
|
|
return t('generationRecord.statusCompleted')
|
|
case 'pending':
|
|
case 'processing':
|
|
return t('generationRecord.statusPending')
|
|
case 'failed':
|
|
case 'error':
|
|
return t('generationRecord.statusFailed')
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status?.toLowerCase()) {
|
|
case 'completed':
|
|
case 'success':
|
|
return '#4ADE80'
|
|
case 'pending':
|
|
case 'processing':
|
|
return '#FF9966'
|
|
case 'failed':
|
|
case 'error':
|
|
return '#FF6B6B'
|
|
default:
|
|
return '#8A8A8A'
|
|
}
|
|
}
|
|
|
|
const formatDate = (date: Date | string) => {
|
|
const d = new Date(date)
|
|
return d.toLocaleString()
|
|
}
|
|
|
|
if (!generationId) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<StatusBar style="light" />
|
|
<RNStatusBar barStyle="light-content" />
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<LeftArrowIcon />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
|
|
<View style={styles.headerSpacer} />
|
|
</View>
|
|
<ErrorState testID="error-state" message={t('generationRecord.notFound')} />
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<StatusBar style="light" />
|
|
<RNStatusBar barStyle="light-content" />
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<LeftArrowIcon />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
|
|
<View style={styles.headerSpacer} />
|
|
</View>
|
|
<LoadingState testID="loading-state" />
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
if (error || !generation) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<StatusBar style="light" />
|
|
<RNStatusBar barStyle="light-content" />
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<LeftArrowIcon />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
|
|
<View style={styles.headerSpacer} />
|
|
</View>
|
|
<ErrorState
|
|
testID="error-state"
|
|
message={error?.message || t('generationRecord.notFound')}
|
|
onRetry={() => execute({ id: generationId })}
|
|
/>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const hasResult = generation.resultUrl && generation.resultUrl.length > 0
|
|
const isVideo = generation.type === 'VIDEO'
|
|
const resultUrl = generation.resultUrl?.[0]
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<StatusBar style="light" />
|
|
<RNStatusBar barStyle="light-content" />
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<LeftArrowIcon />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
|
|
<View style={styles.headerSpacer} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
testID="generation-detail-scroll"
|
|
contentContainerStyle={styles.scrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* 模板信息 */}
|
|
<View style={styles.templateSection}>
|
|
<View style={styles.categoryIcon}>
|
|
<WhiteStarIcon />
|
|
</View>
|
|
<Text style={styles.categoryText}>
|
|
{generation.template?.title || t('generationRecord.aiVideo')}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* 生成结果预览 */}
|
|
<View style={styles.resultSection}>
|
|
<Text style={styles.sectionTitle}>{t('generationRecord.generationResult')}</Text>
|
|
|
|
{hasResult ? (
|
|
<View style={styles.mediaContainer}>
|
|
{isVideo ? (
|
|
<VideoPlayer
|
|
source={resultUrl!}
|
|
poster={generation.webpHighPreviewUrl || generation.webpPreviewUrl || undefined}
|
|
className="w-full h-full rounded-2xl"
|
|
autoPlay={false}
|
|
loop={false}
|
|
controls={true}
|
|
/>
|
|
) : (
|
|
<Image
|
|
source={{ uri: resultUrl }}
|
|
style={styles.resultImage}
|
|
contentFit="cover"
|
|
/>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<View style={styles.noResultContainer}>
|
|
<Text style={styles.noResultText}>{t('generationRecord.noResult')}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 状态信息 */}
|
|
<View style={styles.infoSection}>
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{t('generationRecord.status')}</Text>
|
|
<Text style={[styles.infoValue, { color: getStatusColor(generation.status) }]}>
|
|
{getStatusText(generation.status)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{t('generationRecord.createdAt')}</Text>
|
|
<Text style={styles.infoValue}>{formatDate(generation.createdAt)}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 操作按钮 */}
|
|
<View style={styles.actionSection}>
|
|
{/* 下载按钮 - 主要操作使用渐变色 */}
|
|
{hasResult && (
|
|
<Pressable
|
|
style={[styles.primaryButtonContainer, downloading && styles.buttonDisabled]}
|
|
onPress={handleDownload}
|
|
disabled={downloading}
|
|
>
|
|
<LinearGradient
|
|
colors={['#9966FF', '#FF6699', '#FF9966']}
|
|
locations={[0.0015, 0.4985, 0.9956]}
|
|
start={{ x: 1, y: 0 }}
|
|
end={{ x: 0, y: 0 }}
|
|
style={styles.gradientButton}
|
|
>
|
|
<Text style={styles.primaryButtonText}>
|
|
{downloading
|
|
? `${t('generationRecord.downloading')} ${Math.round(progress * 100)}%`
|
|
: t('generationRecord.download')}
|
|
</Text>
|
|
</LinearGradient>
|
|
</Pressable>
|
|
)}
|
|
|
|
{/* 次要操作按钮行 */}
|
|
<View style={styles.secondaryButtonRow}>
|
|
{/* 重新生成按钮 */}
|
|
<Pressable
|
|
style={[styles.secondaryButton, rerunning && styles.buttonDisabled]}
|
|
onPress={handleRerun}
|
|
disabled={rerunning}
|
|
>
|
|
<ChangeIcon />
|
|
<Text style={styles.secondaryButtonText}>{t('generationRecord.regenerate')}</Text>
|
|
</Pressable>
|
|
|
|
{/* 再来一次按钮 */}
|
|
{generation.template && (
|
|
<Pressable
|
|
style={styles.secondaryButton}
|
|
onPress={handleTryAgain}
|
|
>
|
|
<EditIcon />
|
|
<Text style={styles.secondaryButtonText}>{t('generationRecord.reEdit')}</Text>
|
|
</Pressable>
|
|
)}
|
|
|
|
{/* 删除按钮 */}
|
|
<Pressable
|
|
style={[styles.deleteButton, deleting && styles.buttonDisabled]}
|
|
onPress={() => setDeleteDialogOpen(true)}
|
|
disabled={deleting}
|
|
>
|
|
<DeleteIcon />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
<DeleteConfirmDialog
|
|
open={deleteDialogOpen}
|
|
onOpenChange={setDeleteDialogOpen}
|
|
onConfirm={handleDelete}
|
|
/>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
scrollContent: {
|
|
paddingBottom: 40,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingTop: 16,
|
|
paddingBottom: 20,
|
|
},
|
|
backButton: {
|
|
width: 22,
|
|
height: 22,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
headerTitle: {
|
|
color: '#F5F5F5',
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
flex: 1,
|
|
textAlign: 'center',
|
|
},
|
|
headerSpacer: {
|
|
width: 22,
|
|
},
|
|
templateSection: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
marginBottom: 16,
|
|
gap: 8,
|
|
},
|
|
categoryIcon: {
|
|
width: 20,
|
|
height: 20,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
categoryText: {
|
|
color: '#F5F5F5',
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
resultSection: {
|
|
paddingHorizontal: 12,
|
|
marginBottom: 20,
|
|
},
|
|
sectionTitle: {
|
|
color: '#8A8A8A',
|
|
fontSize: 13,
|
|
marginBottom: 12,
|
|
paddingHorizontal: 4,
|
|
},
|
|
mediaContainer: {
|
|
width: screenWidth - 24,
|
|
height: (screenWidth - 24) * 1.32,
|
|
borderRadius: 16,
|
|
overflow: 'hidden',
|
|
backgroundColor: '#1C1E22',
|
|
},
|
|
resultImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
noResultContainer: {
|
|
width: screenWidth - 24,
|
|
height: 200,
|
|
borderRadius: 16,
|
|
backgroundColor: '#1C1E22',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
noResultText: {
|
|
color: '#8A8A8A',
|
|
fontSize: 14,
|
|
},
|
|
infoSection: {
|
|
paddingHorizontal: 16,
|
|
marginBottom: 24,
|
|
gap: 12,
|
|
},
|
|
infoRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
infoLabel: {
|
|
color: '#8A8A8A',
|
|
fontSize: 14,
|
|
},
|
|
infoValue: {
|
|
color: '#F5F5F5',
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
actionSection: {
|
|
paddingHorizontal: 12,
|
|
gap: 12,
|
|
},
|
|
// 主要按钮 - 渐变色
|
|
primaryButtonContainer: {
|
|
height: 48,
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
},
|
|
gradientButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: 12,
|
|
},
|
|
primaryButtonText: {
|
|
color: '#FFFFFF',
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
// 次要按钮行
|
|
secondaryButtonRow: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
},
|
|
secondaryButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: 48,
|
|
borderRadius: 12,
|
|
backgroundColor: '#262A31',
|
|
gap: 6,
|
|
},
|
|
secondaryButtonText: {
|
|
color: '#F5F5F5',
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
deleteButton: {
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 12,
|
|
backgroundColor: '#262A31',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
buttonDisabled: {
|
|
opacity: 0.5,
|
|
},
|
|
})
|