expo-popcore-app/components/drawer/AIGenerationRecordDrawer.tsx

370 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
Dimensions,
FlatList,
Platform,
ActivityIndicator,
} from 'react-native'
import { Image } from 'expo-image'
import { useTranslation } from 'react-i18next'
import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'
import { CloseIcon } from '@/components/icon'
import { loomart } from '@/lib/auth'
const { width: screenWidth } = Dimensions.get('window')
type DrawerType = 'ai-record' | 'recent' | 'project-all' | 'project-face'
interface AIGenerationRecordDrawerProps {
visible: boolean
onClose: () => void
onSelectImage?: (imageUri: string) => void
type?: DrawerType
}
interface ImageData {
id: string
uri: string
}
export default function AIGenerationRecordDrawer({
visible,
onClose,
onSelectImage,
type = 'ai-record',
}: AIGenerationRecordDrawerProps) {
const { t } = useTranslation()
const bottomSheetRef = useRef<BottomSheet>(null)
const [images, setImages] = useState<ImageData[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
const snapPoints = useMemo(() => ['98%'], [])
const limit = 20
// 根据类型获取数据
const fetchData = useCallback(async (pageNum: number = 1) => {
if (loading) return
try {
setLoading(true)
switch (type) {
case 'ai-record':
// 获取已完成的 AI 生成记录
const result = await loomart.templateGeneration.list({
page: pageNum,
limit,
status: 'completed',
})
const aiImages: ImageData[] = (result.items || []).flatMap((item: any) => {
const resultUrls = item.resultUrl || []
return resultUrls.map((url: string, idx: number) => ({
id: `${item.id}-${idx}`,
uri: url,
}))
})
setImages((prev) => (pageNum === 1 ? aiImages : [...prev, ...aiImages]))
setHasMore(aiImages.length === limit)
break
case 'recent':
// 获取最近使用(按更新时间排序)
const recentResult = await loomart.templateGeneration.list({
page: pageNum,
limit,
})
const recentImages: ImageData[] = (recentResult.items || []).flatMap((item: any) => {
const resultUrls = item.resultUrl || []
return resultUrls.map((url: string, idx: number) => ({
id: `${item.id}-${idx}`,
uri: url,
}))
})
setImages((prev) => (pageNum === 1 ? recentImages : [...prev, ...recentImages]))
setHasMore(recentImages.length === limit)
break
case 'project-all':
// 获取所有项目
const projects = await loomart.project.list({
page: pageNum,
limit,
})
const projectImages: ImageData[] = (projects.items || [])
.filter((p: any) => p.resultUrl)
.map((p: any) => ({
id: p.id,
uri: p.resultUrl,
}))
setImages((prev) => (pageNum === 1 ? projectImages : [...prev, ...projectImages]))
setHasMore(projectImages.length === limit)
break
case 'project-face':
// 获取人脸标签的项目(假设标签名为 'face' 或具体标签ID
// 如果需要先获取人脸标签ID可以调用 loomart.tag.list() 或 loomart.category.list()
const faceProjects = await loomart.project.list({
page: pageNum,
limit,
tagIds: ['face'], // 替换为实际的人脸标签ID
})
const faceImages: ImageData[] = (faceProjects.items || [])
.filter((p: any) => p.resultUrl)
.map((p: any) => ({
id: p.id,
uri: p.resultUrl,
}))
setImages((prev) => (pageNum === 1 ? faceImages : [...prev, ...faceImages]))
setHasMore(faceImages.length === limit)
break
}
} catch (error) {
console.error('获取图片失败:', error)
} finally {
setLoading(false)
}
}, [type, loading])
// 类型改变时重置并重新获取数据
useEffect(() => {
if (visible) {
setImages([])
setPage(1)
setHasMore(true)
fetchData(1)
}
}, [visible, type])
// 滚动加载更多
const handleLoadMore = useCallback(() => {
if (!loading && hasMore) {
const nextPage = page + 1
setPage(nextPage)
fetchData(nextPage)
}
}, [loading, hasMore, page, fetchData])
useEffect(() => {
if (visible) {
bottomSheetRef.current?.expand()
} else {
bottomSheetRef.current?.close()
}
}, [visible])
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
onClose()
}
}, [onClose])
const handleImageSelect = (imageUri: string) => {
onSelectImage?.(imageUri)
onClose()
}
const title = useMemo(() => {
switch (type) {
case 'ai-record':
return t('aiGenerationRecord.title')
case 'recent':
return t('aiGenerationRecord.recentUsed')
case 'project-all':
return t('aiGenerationRecord.projectAll')
case 'project-face':
return t('aiGenerationRecord.projectFace')
default:
return ''
}
}, [type, t])
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
)
const renderImageItem = ({ item, index }: { item: ImageData; index: number }) => {
const gap = 2
const itemWidth = (screenWidth - gap * 2) / 3
return (
<Pressable
style={[
styles.imageItem,
{
width: itemWidth,
marginRight: (index + 1) % 3 !== 0 ? gap : 0,
marginBottom: gap,
},
]}
onPress={() => handleImageSelect(item.uri)}
android_ripple={{ color: 'rgba(255, 255, 255, 0.1)' }}
>
<Image source={{ uri: item.uri }} style={styles.image} contentFit="cover" />
</Pressable>
)
}
const renderFooter = () => {
if (!loading) return null
return (
<View style={styles.footerLoader}>
<ActivityIndicator size="small" color="#666666" />
</View>
)
}
const renderEmpty = () => {
if (loading) return null
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t('common.noData')}</Text>
</View>
)
}
return (
<BottomSheet
ref={bottomSheetRef}
index={visible ? 0 : -1}
snapPoints={snapPoints}
onChange={handleSheetChanges}
enablePanDownToClose
backgroundStyle={styles.bottomSheetBackground}
handleIndicatorStyle={styles.handleIndicator}
backdropComponent={renderBackdrop}
>
<BottomSheetView style={styles.container}>
{/* 顶部标题栏 */}
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<CloseIcon />
</Pressable>
</View>
{/* 图片网格 */}
<FlatList
data={images}
renderItem={renderImageItem}
keyExtractor={(item) => item.id.toString()}
numColumns={3}
contentContainerStyle={styles.imageGrid}
showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty}
removeClippedSubviews={Platform.OS === 'android'}
maxToRenderPerBatch={Platform.OS === 'ios' ? 10 : 5}
updateCellsBatchingPeriod={Platform.OS === 'ios' ? 50 : 100}
initialNumToRender={Platform.OS === 'ios' ? 15 : 10}
windowSize={Platform.OS === 'ios' ? 10 : 5}
getItemLayout={(data, index) => {
const gap = 2
const itemWidth = (screenWidth - gap * 2) / 3
const rowIndex = Math.floor(index / 3)
return {
length: itemWidth,
offset: rowIndex * (itemWidth + gap),
index,
}
}}
/>
</BottomSheetView>
</BottomSheet>
)
}
const styles = StyleSheet.create({
bottomSheetBackground: {
backgroundColor: '#16181B',
},
handleIndicator: {
backgroundColor: '#666666',
},
container: {
flex: 1,
backgroundColor: '#16181B',
paddingTop: 12,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
paddingBottom: 12,
position: 'relative',
},
title: {
color: '#F5F5F5',
fontSize: 15,
fontWeight: '600',
},
closeButton: {
position: 'absolute',
right: 16,
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
imageGrid: {
paddingHorizontal: 0,
paddingBottom: Platform.OS === 'ios' ? 20 : 16,
},
imageItem: {
// aspectRatio = width / height
// 1 : 1.3 (width : height) => 1 / 1.3
aspectRatio: 1 / 1.3,
overflow: 'hidden',
backgroundColor: '#262A31',
},
image: {
width: '100%',
height: '100%',
},
footerLoader: {
paddingVertical: 20,
alignItems: 'center',
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 100,
},
emptyText: {
color: '#666666',
fontSize: 14,
},
})