diff --git a/@share/components/ListEmpty.tsx b/@share/components/ListEmpty.tsx new file mode 100644 index 0000000..060618c --- /dev/null +++ b/@share/components/ListEmpty.tsx @@ -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 ( + + 加载失败 + + 重试 + + + ) + } + return ( + + 暂无数据 + + ) +}) + +export default ListEmpty diff --git a/@share/components/index.ts b/@share/components/index.ts index 95cba5d..204767e 100644 --- a/@share/components/index.ts +++ b/@share/components/index.ts @@ -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' diff --git a/app/(tabs)/generate.tsx b/app/(tabs)/generate.tsx index b134c78..3cc0b71 100644 --- a/app/(tabs)/generate.tsx +++ b/app/(tabs)/generate.tsx @@ -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( + + 生成将消耗 + {selectedTemplate.price} Goo + + } + 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 (
- - + + {!!textCount && } - ), - [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 }, [isLoadingMore]) - const ListEmpty = useMemo(() => { - if (hasError) { - return ( - - 加载失败 - - 重试 - - - ) - } - return ( - - 暂无模板 - - ) - }, [isLoading, hasError, handleRetry]) - return ( @@ -207,7 +298,7 @@ export default function Generate() { estimatedItemSize={itemWidth} extraData={selectedTemplateId} keyExtractor={(item) => item.id} - ListEmptyComponent={ListEmpty} + ListEmptyComponent={} ListFooterComponent={ListFooter} ListHeaderComponent={ListHeader} numColumns={3} @@ -223,8 +314,9 @@ export default function Generate() { ) -} +}) +export default Generate /** ========================= * Small memo components * ========================= */ @@ -298,12 +390,27 @@ type UploadSectionProps = { friendImg: string onPickMe: () => void onPickFriend: () => void + selectedTemplate: Template | undefined } -const UploadSection = memo(function UploadSection({ meImg, friendImg, onPickMe, onPickFriend }) { +const UploadSection = memo(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 ( - - + {imageCount >= 1 && } + {imageCount >= 2 && } ) }) @@ -335,15 +442,6 @@ const PromptSection = memo(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(function TemplateItem({ item, itemW }} onClick={onSelect} > - + {/* */} + {isSelected && } {isSelected && } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9f7949f..ea6023f 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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() { ) + } else { + return ( + + + + + 暂无数据 + + ) } } diff --git a/app/(tabs)/sync.tsx b/app/(tabs)/sync.tsx index 0df0098..240bb66 100644 --- a/app/(tabs)/sync.tsx +++ b/app/(tabs)/sync.tsx @@ -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: '同步成功' }) }) diff --git a/app/_layout.tsx b/app/_layout.tsx index 3e9b0fe..0ed6cda 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 }) { {children} - {/* modals */} - {/* 挂载全局方法 */} ((global as any).actionSheet = ref)} /> ((global as any).modal = ref)} /> ((global as any).loading = ref)} />