expo-duooomi-app/app/(tabs)/generate.tsx

596 lines
19 KiB
TypeScript
Raw 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 { FontAwesome, Fontisto, Ionicons } from '@expo/vector-icons'
import { useIsFocused } from '@react-navigation/native'
import { FlashList } from '@shopify/flash-list'
import { useRouter } from 'expo-router'
import { useShareIntentContext } from 'expo-share-intent'
import { observer } from 'mobx-react-lite'
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { ActivityIndicator, RefreshControl } from 'react-native'
import { useAnimatedStyle } from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { imgPicker } from '@/@share/apis/imgPicker'
import { Block, ConfirmModal, Img, Input, ListEmpty, Text, Toast, VideoBox } from '@/@share/components'
import BannerSection from '@/components/BannerSection'
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
import { useTemplates } from '@/hooks/data/use-templates'
import { userBalanceStore, userStore } from '@/stores'
import { screenWidth, uploadFile } from '@/utils'
const CATEGORY_ID = process.env.EXPO_PUBLIC_GENERATE_GROUP_ID
type Template = {
id: string
name: string
image: string
videoUrl: string
type: 'video'
price?: number
data?: any
}
/** =========================
* Entry page
* ========================= */
const Generate = observer(function Generate() {
const router = useRouter()
const { user, isLogin } = userStore
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntentContext()
const [prompt, setPrompt] = useState(``)
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [meImg, setMeImg] = useState({ uri: '', url: '' })
const [friendImg, setFriendImg] = useState({ uri: '', url: '' })
const templates = useTemplates()
const { runTemplate } = useTemplateActions()
// 直接使用 useIsFocused hook页面失焦时不渲染列表项以减少内存占用
const isFocused = useIsFocused()
// 处理外部分享的图片
useEffect(() => {
if (hasShareIntent && shareIntent?.files?.length > 0) {
const sharedFile = shareIntent.files[0]
if (sharedFile.mimeType?.includes('image/')) {
console.log('sharedFile-------', sharedFile)
const uri = sharedFile.path
setMeImg({ uri, url: '' })
// 上传图片获取 url
uploadFile({
uri,
mimeType: sharedFile.mimeType,
fileName: sharedFile.fileName || 'shared_image.jpg',
}).then((url) => {
if (url) {
setMeImg((state) => ({ uri: state.uri, url }))
}
})
resetShareIntent()
}
}
}, [hasShareIntent, shareIntent, resetShareIntent])
useEffect(() => {
templates.execute({ categoryId: CATEGORY_ID, page: 1, limit: 12, sortBy: 'createdAt', sortOrder: 'desc' })
}, [])
const displayTemplates = useMemo(() => {
const regular = templates.data?.templates || []
const all = regular
return all.map((t: any): Template => {
return {
id: t.id,
name: t.title,
image: t.coverImageUrl,
videoUrl: t.previewUrl,
type: 'video' as const,
price: t.price,
data: {
startNodes: t?.formSchema?.startNodes || [],
},
}
})
}, [templates.data])
const selectedTemplate = useMemo(() => {
return displayTemplates.find((t) => t.id === selectedTemplateId)
}, [displayTemplates, selectedTemplateId])
useEffect(() => {
if (displayTemplates.length > 0 && !selectedTemplateId) {
setSelectedTemplateId(displayTemplates[0].id)
}
}, [displayTemplates, selectedTemplateId])
const handleSearch = useCallback(() => {
router.push('/')
}, [router])
const handleGenerate = async () => {
if (!selectedTemplate) return
if (!isLogin) {
Toast.show({ title: '请先登录' })
return
}
if (!selectedTemplate) {
Toast.show({ title: '请先选择一个模板' })
return
}
// 显示加载状态并刷新余额
Toast.showLoading()
try {
await userBalanceStore.load(true) // 生成前刷新余额
// 使用最新的余额数据进行检查
const currentBalance = userBalanceStore.balance
if (currentBalance < selectedTemplate?.price) {
Toast.show({ title: '余额不足,请充值' })
return
}
} catch (error) {
Toast.show({ title: '余额加载失败,请重试' })
return
} finally {
Toast.hideLoading()
}
Toast.showModal(
<ConfirmModal
content={
<Text className="text-[14px] font-bold">
<Text className="mx-[4px] text-[20px] text-[#e61e25]">{selectedTemplate.price} </Text>
</Text>
}
onCancel={Toast.hideModal}
onConfirm={handleConfirmGenerate}
/>,
)
}
const handleConfirmGenerate = async () => {
Toast.hideModal()
Toast.showLoading()
console.log('meImg.url-------', meImg)
const data = {} as any
const startNodes = selectedTemplate?.data?.startNodes || []
startNodes.map((node: any) => {
if (node.type === 'text') {
data[node.id] = prompt
} else if (node.type === 'image') {
data[node.id] = meImg.url
}
})
console.log('data==========', data)
try {
// 先进行乐观更新,扣减余额
userBalanceStore.deductBalance(selectedTemplate?.price as number)
const { generationId, error } = await runTemplate({
templateId: selectedTemplate?.id as string,
data: data,
})
if (generationId && user?.id) {
// 生成成功后强制刷新余额以获取准确数据
await userBalanceStore.load(true)
Toast.show({ title: '生成任务开启,请在我的生成中查看' })
} else {
// 生成失败,恢复余额
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
Toast.show({ title: error?.message || '生成失败' })
}
} catch (error) {
// 异常情况下恢复余额
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
Toast.show({ title: '网络异常,请重试' })
} finally {
Toast.hideLoading()
}
}
const pickImage = useCallback(async (target: 'me' | 'friend') => {
const assetList = (await imgPicker({ maxImages: 1, resultType: 'asset' })) as string[]
const result = assetList[0] as any
if (!result) return
const uri = result?.uri
if (target === 'me') setMeImg({ uri, url: '' })
else setFriendImg({ uri, url: '' })
const url = await uploadFile({
uri: result.uri,
mimeType: result.mimeType,
fileName: result.fileName,
})
console.log('handlePick------------', url)
// console.log('pickImage---------url:', url, 'error:', error)
if (target === 'me') {
setMeImg((state) => {
return { uri: state.uri, url }
})
} else {
setFriendImg((state) => {
return { uri: state.uri, url }
})
}
}, [])
const handleRandom = useCallback(() => {
if (displayTemplates.length === 0) return
const random = displayTemplates[Math.floor(Math.random() * displayTemplates.length)]
setSelectedTemplateId(random.id)
}, [displayTemplates])
const handleSelectTemplate = useCallback((t: Template) => {
setSelectedTemplateId(t.id)
}, [])
const onPickMe = useCallback(() => pickImage('me'), [pickImage])
const onPickFriend = useCallback(() => pickImage('friend'), [pickImage])
const isLoading = templates.loading
const isLoadingMore = templates.loadingMore
const hasError = templates.error
const handleRetry = useCallback(() => {
templates.refetch({ categoryId: CATEGORY_ID, page: 1, limit: 20, sortBy: 'createdAt', sortOrder: 'desc' })
}, [templates])
const [refreshing, setRefreshing] = useState(false)
const onRefresh = useCallback(async () => {
setRefreshing(true)
try {
await templates.refetch({ categoryId: CATEGORY_ID, page: 1, limit: 20, sortBy: 'createdAt', sortOrder: 'desc' })
} finally {
setRefreshing(false)
}
}, [templates])
const onLoadMore = useCallback(() => {
templates.loadMore({ categoryId: CATEGORY_ID, limit: 20, sortBy: 'createdAt', sortOrder: 'desc' })
}, [templates])
const itemWidth = useMemo(() => {
return Math.floor((screenWidth - 24 - 12 * 2) / 3)
}, [])
const renderItem = useCallback(
({ item }: { item: Template }) => {
const isSelected = selectedTemplateId === item.id
return (
<TemplateItem
isSelected={isSelected}
item={item}
key={item?.id}
itemWidth={itemWidth}
// 页面失焦时不渲染减少内存占用
isVisible={isFocused}
onSelect={() => handleSelectTemplate(item)}
/>
)
},
[selectedTemplateId, itemWidth, handleSelectTemplate, isFocused],
)
const ListHeader = useMemo(() => {
const startNodes = selectedTemplate?.data?.startNodes || []
const textCount = startNodes.filter((node: any) => node?.type === 'text').length
return (
<Block className="">
<Header onSearch={handleSearch} />
<Block className="px-12px relative z-10">
<UploadSection
friendImg={friendImg?.uri}
meImg={meImg?.uri}
onPickFriend={onPickFriend}
onPickMe={onPickMe}
selectedTemplate={selectedTemplate}
/>
{!!textCount && <PromptSection prompt={prompt} onChangePrompt={setPrompt} />}
<TemplateSectionHeader onRandom={handleRandom} />
</Block>
</Block>
)
}, [handleSearch, friendImg, meImg, onPickFriend, onPickMe, prompt, handleRandom, selectedTemplate])
const ListFooter = useMemo(() => {
if (isLoadingMore) {
return (
<Block className="items-center justify-center py-[20px]">
<ActivityIndicator color="#FFE500" size="small" />
</Block>
)
}
return <Block className="h-[200px] w-full" />
}, [isLoadingMore])
return (
<Block className="relative flex-1 overflow-visible bg-black">
<BannerSection />
<FlashList
contentContainerStyle={{ paddingHorizontal: 12, paddingBottom: 200 }}
data={displayTemplates}
drawDistance={300}
// @ts-ignore
estimatedItemSize={itemWidth}
extraData={selectedTemplateId}
keyExtractor={(item) => item.id}
ListEmptyComponent={<ListEmpty hasError={hasError} handleRetry={handleRetry} />}
ListFooterComponent={ListFooter}
ListHeaderComponent={ListHeader}
numColumns={3}
maxItemsInRecyclePool={0}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl colors={['#FFE500']} refreshing={refreshing} tintColor="#fff" onRefresh={onRefresh} />
}
onEndReached={onLoadMore}
onEndReachedThreshold={0.3}
/>
<GenerateSection selectedTemplate={selectedTemplate} onGenerate={handleGenerate} />
</Block>
)
})
export default Generate
/** =========================
* Small memo components
* ========================= */
type HeaderProps = { onSearch: () => void }
const Header = memo<HeaderProps>(function Header({ onSearch }) {
return (
<Block className="z-20 flex-row items-center justify-between py-[12px]">
{/* <Block className="-skew-x-12 flex-row items-center gap-[8px] border border-white/20 bg-black px-[12px] py-[4px]">
<Block className="size-[8px] bg-accent" />
<Text className="font-700 skew-x-12 text-[12px] tracking-wider text-white">创造终端</Text>
</Block>
<Block className="items-center">
<Text className="font-700 text-[24px] tracking-tighter text-white">GEN_STUDIO</Text>
</Block> */}
<Block className="-skew-x-12 flex-row items-center gap-[8px] border border-white/20 bg-black px-[12px] py-[4px] opacity-0">
<Block className="size-[8px] bg-accent" />
<Text className="font-700 skew-x-12 text-[12px] tracking-wider text-white"></Text>
</Block>
</Block>
)
})
type UploadCardProps = {
variant: 'me' | 'friend'
img: string
onPick: () => void
}
const UploadCard = memo<UploadCardProps>(function UploadCard({ variant, img, onPick }) {
const isMe = variant === 'me'
return (
<Block className="flex-1 overflow-hidden border-[3px] border-black bg-white" onClick={onPick}>
{isMe ? (
<Block className="absolute left-0 top-0 z-10 border-b-2 border-r-2 border-black bg-accent px-[8px] py-[2px]">
<Text className="text-[10px] font-[900] text-black"></Text>
</Block>
) : (
<Block className="absolute right-0 top-0 z-10 border-b-2 border-l-2 border-white bg-black px-[8px] py-[2px]">
<Text className="text-[10px] font-[900] text-white"></Text>
</Block>
)}
<Block className="size-full items-center justify-center bg-[#F3F4F6]">
{img ? (
<Img className="size-full" contentFit="cover" src={img} />
) : (
<Block className="items-center">
{isMe ? (
<Block className="mb-[4px] size-[40px] items-center justify-center rounded-[20px] border-2 border-black bg-white">
<Text className="font-700 text-[16px] text-black">+</Text>
</Block>
) : (
<Block className="mb-[4px] size-[40px] items-center justify-center rounded-[20px] bg-black">
<Text className="font-700 text-[16px] text-white">+</Text>
</Block>
)}
<Text className="font-700 text-[9px] text-black/50"></Text>
</Block>
)}
</Block>
</Block>
)
})
type UploadSectionProps = {
meImg: string
friendImg: string
onPickMe: () => void
onPickFriend: () => void
selectedTemplate: Template | undefined
}
const UploadSection = memo<UploadSectionProps>(function UploadSection({
meImg,
friendImg,
onPickMe,
onPickFriend,
selectedTemplate,
}) {
const imageCount = useMemo(() => {
if (!selectedTemplate) return true
const startNodes = selectedTemplate?.data?.startNodes || []
const imageNodeCount = startNodes.filter((node: any) => node?.type === 'image').length
return imageNodeCount
}, [selectedTemplate])
if (imageCount === 0) return null
return (
<Block className="z-10 flex h-[160px] w-full flex-row gap-x-[12px]">
{imageCount >= 1 && <UploadCard img={meImg} variant="me" onPick={onPickMe} />}
{imageCount >= 2 && <UploadCard img={friendImg} variant="friend" onPick={onPickFriend} />}
</Block>
)
})
type PromptSectionProps = {
prompt: string
editable?: boolean
onChangePrompt: (v: string) => void
}
const PromptSection = memo<PromptSectionProps>(function PromptSection({ prompt, onChangePrompt, editable = true }) {
return (
<Block className="mt-[24px] -skew-x-6">
<Block className="mb-[4px] flex-row items-center px-[4px]">
<Block className="border border-white/20 bg-black px-[8px] py-[2px]">
<Text className="text-[12px] font-[900] text-accent"></Text>
</Block>
</Block>
<Block className={`border-[3px] border-black bg-white p-[4px]`}>
<Input
multiline
className="w-full bg-transparent p-[8px] text-[14px] leading-[18px] text-black"
style={{ height: 50 }}
numberOfLines={2}
placeholder={editable ? '描述画面细节...' : '请先选择模板'}
value={prompt}
editable={editable}
onChangeText={onChangePrompt}
/>
</Block>
</Block>
)
})
type TemplateItemProps = {
item: Template
itemWidth: number
isSelected: boolean
isVisible?: boolean
onSelect: () => void
}
const TemplateItem = memo<TemplateItemProps>(function TemplateItem({
item,
itemWidth,
isSelected,
isVisible = true,
onSelect,
}) {
return (
<Block
key={item?.id}
className={`relative overflow-hidden border-2 ${isSelected ? 'border-accent' : 'border-black'}`}
style={{
transform: [{ skewX: '-6deg' }],
height: itemWidth,
width: itemWidth,
borderWidth: 2,
marginBottom: 12,
borderColor: isSelected ? '#FFE500' : '#000000',
}}
onClick={onSelect}
>
{/* <Img className="size-full" contentFit="cover" src={item.image} /> */}
{isVisible && <VideoBox style={{ width: itemWidth, height: itemWidth }} url={item.videoUrl} />}
{isSelected && <Block className="absolute inset-0 bg-black/60" />}
{isSelected && <Block className="absolute inset-[-5px] border-[3px] border-accent" />}
<Block className="absolute inset-x-0 bottom-0 items-center bg-black/90 p-[4px]">
<Text className={`text-[8px] font-[700] ${isSelected ? 'text-accent' : 'text-white'}`}>{item.name}</Text>
</Block>
{item.type === 'video' && (
<Block className="rounded-4px absolute right-[4px] top-[4px] rounded-[4px] border border-white/50 bg-black p-[4px]">
<Fontisto color="white" name="youtube-play" size={8} />
</Block>
)}
</Block>
)
})
type TemplateSectionHeaderProps = {
onRandom: () => void
}
const TemplateSectionHeader = memo<TemplateSectionHeaderProps>(function TemplateSectionHeader({ onRandom }) {
const style = useAnimatedStyle(() => ({
transform: [{ skewX: '-6deg' }],
backgroundColor: '#FFE500',
}))
return (
<Block className="my-[16px]">
<Block className="flex-row items-center justify-between">
<Block
animated
className="flex-row items-center gap-[8px] border-2 border-black bg-accent px-[12px] py-[4px]"
style={style}
>
<FontAwesome color="black" name="film" size={16} />
<Text className="text-[10px] font-[900] text-black"></Text>
</Block>
<Block
className="-skew-x-6 items-center justify-center border-2 border-black bg-white px-[8px] py-[4px]"
onClick={onRandom}
>
<Text className="text-[10px] font-[900] text-black"></Text>
</Block>
</Block>
</Block>
)
})
type GenerateSectionProps = {
selectedTemplate: Template | undefined
onGenerate: () => void
}
const GenerateSection = memo<GenerateSectionProps>(function GenerateSection({ selectedTemplate, onGenerate }) {
const insets = useSafeAreaInsets()
if (!selectedTemplate) return null
const price = selectedTemplate.price || selectedTemplate.data?.price || 0
return (
<Block className="absolute inset-x-[16px] bottom-[96px] z-40" style={{ paddingBottom: insets.bottom }}>
<Block style={{ transform: [{ skewX: '-6deg' }] }}>
<Block
className={`relative flex-row items-center justify-between border-[3px] border-black bg-accent py-[8px]`}
style={{ paddingHorizontal: 24 }}
onClick={onGenerate}
>
{/* 左侧文字 */}
<Block className="z-10 flex-col">
<Text className={`text-[24px] text-black`}></Text>
<Text className={`text-[10px] font-bold text-black`}></Text>
</Block>
{/* 右侧 标签 */}
<Block className="z-10 flex-row items-center gap-[8px] border-2 border-black bg-black px-[12px] py-[4px]">
<Ionicons color="#FFE500" name="flash" size={14} />
<Text className="text-[14px] font-black text-accent">{price}</Text>
</Block>
</Block>
</Block>
</Block>
)
})