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)} />