feat: 添加 ListEmpty 组件,优化空数据和加载失败的展示逻辑

This commit is contained in:
康猛 2026-01-07 17:57:55 +08:00
parent 159ebbae45
commit 474b4ca8b7
6 changed files with 249 additions and 111 deletions

View File

@ -0,0 +1,25 @@
import { memo } from 'react'
import Block from './Block'
import Text from './Text'
const ListEmpty = memo((props: any) => {
const { hasError = false, handleRetry = () => {} } = props
if (hasError) {
return (
<Block className="items-center justify-center py-[40px]">
<Text className="mb-[8px] text-[14px] text-white/60"></Text>
<Block className="-skew-x-6 border-2 border-white bg-white px-[16px] py-[6px]" onClick={handleRetry}>
<Text className="text-[12px] font-[900] text-black"></Text>
</Block>
</Block>
)
}
return (
<Block className="items-center justify-center py-[40px]">
<Text className="text-[14px] text-white/60"></Text>
</Block>
)
})
export default ListEmpty

View File

@ -2,6 +2,7 @@ export { default as Block } from './Block'
export { default as ConfirmModal } from './ConfirmModal'
export { default as Img } from './Img'
export { default as Input } from './Input'
export { default as ListEmpty } from './ListEmpty'
export { default as Modal } from './Modal'
export { default as ModalPortal } from './ModalPortal'
export { default as Text } from './Text'

View File

@ -1,36 +1,47 @@
import { FontAwesome, Fontisto, Ionicons } from '@expo/vector-icons'
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, Platform, RefreshControl } from 'react-native'
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, Img, Input, Text } from '@/@share/components'
import { Block, ConfirmModal, Img, Input, ListEmpty, Text, Toast, VideoBox } from '@/@share/components'
import BannerSection from '@/components/BannerSection'
import { useFileUpload } from '@/hooks/actions'
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
import { useTemplates } from '@/hooks/data/use-templates'
import { screenHeight, screenWidth } from '@/utils'
import { userBalanceStore, userStore } from '@/stores'
import { screenHeight, screenWidth, uploadFile } from '@/utils'
const CATEGORY_ID = process.env.EXPO_PUBLIC_GENERATE_GROUP_ID
const CATEGORY_ID = 'cmk3qbw9p0008j2eb84etgxcb'
type Template = {
id: string
name: string
image: string
videoUrl: string
type: 'video'
price?: number
data?: any
}
/** =========================
* Entry page
* ========================= */
export default function Generate() {
const Generate = observer(function Generate() {
const router = useRouter()
const env = process.env.EXPO_PUBLIC_ENV
const { user, isAuthenticated } = userStore
const [prompt, setPrompt] = useState(`${env} update`)
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [meImg, setMeImg] = useState('')
const [friendImg, setFriendImg] = useState('')
const [meImg, setMeImg] = useState({ uri: '', url: '' })
const [friendImg, setFriendImg] = useState({ uri: '', url: '' })
const templates = useTemplates()
const { uploadFile, loading: uploadLoading } = useFileUpload()
const { runTemplate } = useTemplateActions()
useEffect(() => {
templates.execute({ categoryId: CATEGORY_ID, page: 1, limit: 12, sortBy: 'createdAt', sortOrder: 'desc' })
@ -39,16 +50,19 @@ export default function Generate() {
const displayTemplates = useMemo(() => {
const regular = templates.data?.templates || []
const all = regular
return all.map(
(t: any): Template => ({
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: t,
}),
)
data: {
startNodes: t?.formSchema?.startNodes || [],
},
}
})
}, [templates.data])
const selectedTemplate = useMemo(() => {
@ -62,13 +76,97 @@ export default function Generate() {
}, [displayTemplates, selectedTemplateId])
const handleSearch = useCallback(() => {
router.push('/searchTemplate')
router.push('/')
}, [router])
const handleGenerate = useCallback(() => {
const handleGenerate = async () => {
if (!selectedTemplate) return
setTimeout(() => {}, 2000)
}, [selectedTemplate])
if (!isAuthenticated) {
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} Goo</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[]
@ -77,25 +175,28 @@ export default function Generate() {
if (!result) return
const uri = result?.uri
if (target === 'me') setMeImg(uri)
else setFriendImg(uri)
if (target === 'me') setMeImg({ uri, url: '' })
else setFriendImg({ uri, url: '' })
const file = {
name: result.fileName || `image_${Date.now()}.jpg`,
type: result.mimeType || 'image/jpeg',
uri: Platform.OS === 'android' ? result.uri : result.uri.replace('file://', ''),
}
const formData = new FormData()
formData.append('file', file as any)
const url = await uploadFile({
uri: result.uri,
mimeType: result.mimeType,
fileName: result.fileName,
})
console.log('handlePick------------', url)
const { url, error } = await uploadFile(file as any)
// console.log('pickImage---------url:', url, 'error:', error)
if (error || !url) {
return
if (target === 'me') {
setMeImg((state) => {
return { uri: state.uri, url }
})
} else {
setFriendImg((state) => {
return { uri: state.uri, url }
})
}
if (target === 'me') setMeImg(url)
else setFriendImg(url)
}, [])
const handleRandom = useCallback(() => {
@ -152,19 +253,27 @@ export default function Generate() {
[selectedTemplateId, itemWidth, handleSelectTemplate],
)
const ListHeader = useMemo(
() => (
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 flex-1">
<UploadSection friendImg={friendImg} meImg={meImg} onPickFriend={onPickFriend} onPickMe={onPickMe} />
<PromptSection prompt={prompt} onChangePrompt={setPrompt} />
<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],
)
)
}, [handleSearch, friendImg, meImg, onPickFriend, onPickMe, prompt, handleRandom, selectedTemplate])
const ListFooter = useMemo(() => {
if (isLoadingMore) {
@ -177,24 +286,6 @@ export default function Generate() {
return <Block className="h-[200px] w-full" />
}, [isLoadingMore])
const ListEmpty = useMemo(() => {
if (hasError) {
return (
<Block className="items-center justify-center py-[40px]">
<Text className="mb-[8px] text-[14px] text-white/60"></Text>
<Block className="-skew-x-6 border-2 border-white bg-white px-[16px] py-[6px]" onClick={handleRetry}>
<Text className="text-[12px] font-[900] text-black"></Text>
</Block>
</Block>
)
}
return (
<Block className="items-center justify-center py-[40px]">
<Text className="text-[14px] text-white/60"></Text>
</Block>
)
}, [isLoading, hasError, handleRetry])
return (
<Block className="relative flex-1 overflow-visible bg-black">
<BannerSection />
@ -207,7 +298,7 @@ export default function Generate() {
estimatedItemSize={itemWidth}
extraData={selectedTemplateId}
keyExtractor={(item) => item.id}
ListEmptyComponent={ListEmpty}
ListEmptyComponent={<ListEmpty hasError={hasError} handleRetry={handleRetry} />}
ListFooterComponent={ListFooter}
ListHeaderComponent={ListHeader}
numColumns={3}
@ -223,8 +314,9 @@ export default function Generate() {
<GenerateSection selectedTemplate={selectedTemplate} onGenerate={handleGenerate} />
</Block>
)
}
})
export default Generate
/** =========================
* Small memo components
* ========================= */
@ -298,12 +390,27 @@ type UploadSectionProps = {
friendImg: string
onPickMe: () => void
onPickFriend: () => void
selectedTemplate: Template | undefined
}
const UploadSection = memo<UploadSectionProps>(function UploadSection({ meImg, friendImg, onPickMe, onPickFriend }) {
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]">
<UploadCard img={meImg} variant="me" onPick={onPickMe} />
<UploadCard img={friendImg} variant="friend" onPick={onPickFriend} />
{imageCount >= 1 && <UploadCard img={meImg} variant="me" onPick={onPickMe} />}
{imageCount >= 2 && <UploadCard img={friendImg} variant="friend" onPick={onPickFriend} />}
</Block>
)
})
@ -335,15 +442,6 @@ const PromptSection = memo<PromptSectionProps>(function PromptSection({ prompt,
)
})
type Template = {
id: string
name: string
image: string
type: 'video'
price?: number
data?: any
}
type TemplateItemProps = {
item: Template
itemWidth: number
@ -364,7 +462,8 @@ const TemplateItem = memo<TemplateItemProps>(function TemplateItem({ item, itemW
}}
onClick={onSelect}
>
<Img className="size-full" contentFit="cover" src={item.image} />
{/* <Img className="size-full" contentFit="cover" src={item.image} /> */}
<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" />}

View File

@ -247,7 +247,7 @@ const Index = observer(function Index() {
useCallback(() => {
// 页面获得焦点时使用防抖加载余额,只有在用户已登录时才加载
if (isAuthenticated && user?.id) {
userBalanceStore.load(false)
userBalanceStore.load(true)
}
}, [isAuthenticated, user?.id]),
)
@ -302,7 +302,6 @@ const Index = observer(function Index() {
const { generationId, error } = await runTemplate({
templateId: selectedItem.id,
data: {},
originalUrl: selectedItem.url,
})
if (generationId && user?.id) {
@ -347,6 +346,15 @@ const Index = observer(function Index() {
</Block>
</Block>
)
} else {
return (
<Block className="mt-[40px] items-center justify-center gap-[16px] py-[60px]">
<Block className="size-[80px] items-center justify-center rounded-full border-4 border-white/20 bg-white/10">
<Ionicons color="rgba(255,255,255,0.6)" name="images-outline" size={40} />
</Block>
<Text className="text-[16px] font-bold text-white/80"></Text>
</Block>
)
}
}

View File

@ -37,6 +37,7 @@ const Sync = observer(() => {
const [selectedItem, setSelectedItem] = useState({
id: '',
imageUrl: '',
// url 是网络地址,本地预览使用 imageUrl
url: '',
originalUrl: '',
templateId: '',
@ -81,21 +82,25 @@ const Sync = observer(() => {
const generations = generationsData?.data || []
return generations
.filter((gen: any) => gen?.id) // 过滤掉没有 id 的记录
.map((gen: any) => ({
id: gen?.id,
imageUrl: Array.isArray(gen?.resultUrl) ? gen?.resultUrl[0] : gen?.resultUrl,
originalUrl: gen?.originalUrl,
templateId: gen?.templateId,
type: gen?.type,
status: gen?.status,
createdAt: gen?.createdAt,
title: `生成-${gen?.id.slice(0, 6)}`,
rank: 'S',
author: user?.name || 'User',
avatarUrl:
user?.image ||
'https://image.pollinations.ai/prompt/cool%20anime%20boy%20avatar%20hoodie?seed=123&nologo=true',
}))
.map((gen: any) => {
const imageUrl = Array.isArray(gen?.resultUrl) ? gen?.resultUrl[0] : gen?.resultUrl
return {
id: gen?.id,
imageUrl: imageUrl,
url: imageUrl,
originalUrl: gen?.originalUrl,
templateId: gen?.templateId,
type: gen?.type,
status: gen?.status,
createdAt: gen?.createdAt,
title: `生成-${gen?.id.slice(0, 6)}`,
rank: 'S',
author: user?.name || 'User',
avatarUrl:
user?.image ||
'https://image.pollinations.ai/prompt/cool%20anime%20boy%20avatar%20hoodie?seed=123&nologo=true',
}
})
}, [generationsData, user])
useEffect(() => {
@ -106,7 +111,7 @@ const Sync = observer(() => {
const newItem = {
id: firstItem.id,
imageUrl: firstItem.imageUrl,
url: firstItem.imageUrl,
url: firstItem.url,
originalUrl: firstItem.originalUrl,
templateId: firstItem.templateId,
}
@ -172,7 +177,7 @@ const Sync = observer(() => {
duration: 0,
})
transferMediaSingle(selectedItem?.imageUrl)
transferMediaSingle(selectedItem?.url)
.then(() => {
Toast.show({ title: '同步成功' })
})

View File

@ -18,18 +18,11 @@ import { useUserSession } from '@/stores/UserStore'
Sentry.init({
dsn: 'https://ef710a118839b1e86e38a3833a9a3c6c@o4507705403965440.ingest.us.sentry.io/4510576286302208',
// Adds more context data to events (IP address, cookies, user, etc.)
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
sendDefaultPii: true,
enableLogs: true,
// debug: true,
enabled: !__DEV__, // 仅在生产环境启用 Sentry
enabled: !__DEV__,
})
// 目前无需求,先注释掉
// SplashScreen.preventAutoHideAsync()
// 全局启用 fetch 日志记录
if (__DEV__) {
setupGlobalFetchLogger()
}
@ -38,7 +31,6 @@ export const unstable_settings = {
anchor: '(tabs)',
}
// 路由层
function RootLayout() {
const ref = useNavigationContainerRef()
@ -46,16 +38,17 @@ function RootLayout() {
if (!ref?.current) return
const unsubscribe = ref.addListener('state', (e) => {
const routes = e.data.state?.routes
if (routes && routes.length > 0) {
// Get the current active route
const currentRoute = routes[routes.length - 1]
try {
const routes = e.data.state?.routes
if (!routes || routes.length === 0) return
const currentRoute = routes[routes.length - 1]
if (!currentRoute) return
// Extract the actual screen name from the route
let screenName = currentRoute.name
let params = currentRoute.params
// Handle nested routes and get the actual screen name
// 处理嵌套路由
if (currentRoute.state?.routes) {
const nestedRoutes = currentRoute.state.routes
const activeNestedRoute = nestedRoutes[nestedRoutes.length - 1]
@ -65,7 +58,16 @@ function RootLayout() {
}
}
console.warn(`screenName------------${screenName}, params ----------${JSON.stringify(params)}`)
// 使用 debug 而非 warn避免噪音
if (__DEV__) {
console.debug(`📍 Navigation: ${screenName}`, params ? `(${JSON.stringify(params)})` : '')
}
// 上报到 Sentry可选
Sentry.captureMessage(`Navigation: ${screenName}`, 'info')
} catch (error) {
console.error('❌ Navigation listener error:', error)
Sentry.captureException(error)
}
})
@ -102,9 +104,7 @@ function Providers({ children }: { children: React.ReactNode }) {
<HotUpdate />
{children}
</SafeAreaView>
{/* modals */}
{/* 挂载全局方法 */}
<ModalPortal ref={(ref) => ((global as any).actionSheet = ref)} />
<ModalPortal ref={(ref) => ((global as any).modal = ref)} />
<ModalPortal ref={(ref) => ((global as any).loading = ref)} />