569 lines
18 KiB
TypeScript
569 lines
18 KiB
TypeScript
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 { 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 [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(() => {
|
||
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>
|
||
)
|
||
})
|