fix: bug
This commit is contained in:
@@ -55,6 +55,14 @@ const ErrorState = () => (
|
|||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const EmptyState = () => (
|
||||||
|
<Layout>
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Text style={styles.messageText}>暂无视频模板</Text>
|
||||||
|
</View>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
|
||||||
const FooterLoading = () => (
|
const FooterLoading = () => (
|
||||||
<View style={styles.footerLoading}>
|
<View style={styles.footerLoading}>
|
||||||
<ActivityIndicator size="small" color="#FFE500" />
|
<ActivityIndicator size="small" color="#FFE500" />
|
||||||
@@ -171,19 +179,37 @@ export default function VideoScreen() {
|
|||||||
index,
|
index,
|
||||||
}), [videoHeight])
|
}), [videoHeight])
|
||||||
|
|
||||||
// 过滤掉视频类型的 item
|
// 过滤掉没有可用预览的 item
|
||||||
const filteredTemplates = templates.filter((item: TemplateDetail) => {
|
const filteredTemplates = templates.filter((item: TemplateDetail) => {
|
||||||
// 检查所有可能的预览 URL,如果任何一个包含视频格式则过滤掉
|
// 优先使用 WebP 格式(支持动画),回退到普通预览图
|
||||||
const hasVideoUrl = [
|
const displayUrl = item.webpHighPreviewUrl || item.webpPreviewUrl || item.previewUrl
|
||||||
item.webpHighPreviewUrl,
|
|
||||||
item.webpPreviewUrl,
|
// 只检查最终要显示的 URL 是否为视频格式
|
||||||
item.previewUrl,
|
const isDisplayVideo = displayUrl && isVideoUrl(displayUrl)
|
||||||
].some(url => url && isVideoUrl(url))
|
|
||||||
return !hasVideoUrl
|
// 有显示URL且不是视频格式才保留
|
||||||
|
const shouldKeep = !!displayUrl && !isDisplayVideo
|
||||||
|
|
||||||
|
console.log(`Filtering ${item.title}:`, {
|
||||||
|
displayUrl,
|
||||||
|
isDisplayVideo,
|
||||||
|
shouldKeep,
|
||||||
|
})
|
||||||
|
|
||||||
|
return shouldKeep
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Video page state:', {
|
||||||
|
templatesLength: templates.length,
|
||||||
|
filteredTemplatesLength: filteredTemplates.length,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasMore,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (loading && templates.length === 0) return <LoadingState />
|
if (loading && templates.length === 0) return <LoadingState />
|
||||||
if (error && templates.length === 0) return <ErrorState />
|
if (error && templates.length === 0) return <ErrorState />
|
||||||
|
if (!loading && filteredTemplates.length === 0) return <EmptyState />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Pressable,
|
Pressable,
|
||||||
TextInput,
|
|
||||||
StatusBar as RNStatusBar,
|
StatusBar as RNStatusBar,
|
||||||
Platform,
|
Platform,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -16,14 +15,13 @@ import { SafeAreaView } from 'react-native-safe-area-context'
|
|||||||
import { Image } from 'expo-image'
|
import { Image } from 'expo-image'
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
|
||||||
|
|
||||||
import { LeftArrowIcon, UploadIcon, WhitePointsIcon } from '@/components/icon'
|
import { LeftArrowIcon, WhitePointsIcon } from '@/components/icon'
|
||||||
import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer'
|
import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer'
|
||||||
import { StartGeneratingNotification } from '@/components/ui'
|
import { StartGeneratingNotification } from '@/components/ui'
|
||||||
|
import { DynamicForm, type DynamicFormRef, type FormSchema } from '@/components/DynamicForm'
|
||||||
import { useTemplateActions } from '@/hooks/use-template-actions'
|
import { useTemplateActions } from '@/hooks/use-template-actions'
|
||||||
import { useTemplateDetail } from '@/hooks/use-template-detail'
|
import { useTemplateDetail } from '@/hooks/use-template-detail'
|
||||||
import { uploadFile } from '@/lib/uploadFile'
|
|
||||||
import Toast from '@/components/ui/Toast'
|
import Toast from '@/components/ui/Toast'
|
||||||
|
|
||||||
const { height: screenHeight } = Dimensions.get('window')
|
const { height: screenHeight } = Dimensions.get('window')
|
||||||
@@ -35,11 +33,10 @@ export default function GenerateVideoScreen() {
|
|||||||
const { runTemplate, loading } = useTemplateActions()
|
const { runTemplate, loading } = useTemplateActions()
|
||||||
const { data: templateDetail, loading: templateLoading, execute: fetchTemplate } = useTemplateDetail()
|
const { data: templateDetail, loading: templateLoading, execute: fetchTemplate } = useTemplateDetail()
|
||||||
|
|
||||||
const [description, setDescription] = useState('')
|
|
||||||
const [uploadedImageUrl, setUploadedImageUrl] = useState('')
|
|
||||||
const [previewImageUri, setPreviewImageUri] = useState('')
|
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||||
const [showNotification, setShowNotification] = useState(false)
|
const [showNotification, setShowNotification] = useState(false)
|
||||||
|
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
|
||||||
|
const dynamicFormRef = useRef<DynamicFormRef>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.templateId && typeof params.templateId === 'string') {
|
if (params.templateId && typeof params.templateId === 'string') {
|
||||||
@@ -47,68 +44,43 @@ export default function GenerateVideoScreen() {
|
|||||||
}
|
}
|
||||||
}, [params.templateId, fetchTemplate])
|
}, [params.templateId, fetchTemplate])
|
||||||
|
|
||||||
useEffect(() => {
|
const formSchema = useMemo<FormSchema>(() => ({
|
||||||
if (templateDetail) {
|
startNodes: templateDetail?.formSchema?.startNodes || []
|
||||||
if (templateDetail.thumbnailUrl) {
|
}), [templateDetail])
|
||||||
setPreviewImageUri(templateDetail.thumbnailUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [templateDetail])
|
|
||||||
|
|
||||||
const startNodes = useMemo(() => templateDetail?.formSchema?.startNodes || [], [templateDetail])
|
const handleOpenDrawer = useCallback((nodeId: string) => {
|
||||||
const hasImageNode = useMemo(() => startNodes.some((node) => node.type === 'image'), [startNodes])
|
setCurrentNodeId(nodeId)
|
||||||
|
setDrawerVisible(true)
|
||||||
const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => {
|
|
||||||
try {
|
|
||||||
setPreviewImageUri(imageUri)
|
|
||||||
const url = await uploadFile({ uri: imageUri, mimeType, fileName })
|
|
||||||
setUploadedImageUrl(url)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload failed:', error)
|
|
||||||
} finally {
|
|
||||||
setDrawerVisible(false)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => {
|
||||||
if (!templateDetail) return
|
if (!currentNodeId) return
|
||||||
|
|
||||||
if (hasImageNode && !uploadedImageUrl) {
|
if (dynamicFormRef.current) {
|
||||||
Toast.show({ title: t('generateVideo.pleaseUploadImage') || '请上传参考图片' })
|
dynamicFormRef.current.updateFieldValue(currentNodeId, imageUri, imageUri)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
setDrawerVisible(false)
|
||||||
|
setCurrentNodeId(null)
|
||||||
|
}, [currentNodeId])
|
||||||
|
|
||||||
Toast.showLoading()
|
const handleFormSubmit = useCallback(async (data: Record<string, string>) => {
|
||||||
try {
|
if (!templateDetail) return { error: { message: 'Template not found' } }
|
||||||
const data: Record<string, string> = {}
|
|
||||||
startNodes.forEach((node) => {
|
|
||||||
data[node.id] = node.type === 'text' ? description : uploadedImageUrl
|
|
||||||
})
|
|
||||||
|
|
||||||
const { generationId, error } = await runTemplate({
|
const result = await runTemplate({
|
||||||
templateId: templateDetail.id,
|
templateId: templateDetail.id,
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
|
|
||||||
Toast.hideLoading()
|
if (result.generationId) {
|
||||||
|
|
||||||
if (error) {
|
|
||||||
Toast.show({ title: error.message || t('generateVideo.generateFailed') || '生成失败' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generationId) {
|
|
||||||
setShowNotification(true)
|
setShowNotification(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setShowNotification(false)
|
setShowNotification(false)
|
||||||
router.back()
|
router.back()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
Toast.hideLoading()
|
return result
|
||||||
Toast.show({ title: t('generateVideo.generateFailed') || '生成失败' })
|
}, [templateDetail, runTemplate, router])
|
||||||
}
|
|
||||||
}, [templateDetail, uploadedImageUrl, description, runTemplate, router, t, hasImageNode, startNodes])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
@@ -143,85 +115,66 @@ export default function GenerateVideoScreen() {
|
|||||||
<LeftArrowIcon />
|
<LeftArrowIcon />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* 标题区域 */}
|
{/* 模板预览卡片 */}
|
||||||
<View style={styles.titleSection}>
|
<View style={styles.templatePreviewCard}>
|
||||||
<Text style={styles.title}> {templateDetail?.title}</Text>
|
<View style={styles.templateThumbnailContainer}>
|
||||||
<Text style={styles.subtitle}>
|
<Image
|
||||||
{templateDetail?.title }
|
source={templateDetail?.coverImageUrl || ''}
|
||||||
</Text>
|
style={styles.templateThumbnail}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
<View style={styles.templateInfo}>
|
||||||
|
<Text style={styles.templateTitle}>{templateDetail?.title}</Text>
|
||||||
|
<Text style={styles.templateDescription}>{templateDetail?.description}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* 价格标签 */}
|
||||||
|
<View style={styles.priceTag}>
|
||||||
|
<WhitePointsIcon />
|
||||||
|
<Text style={styles.priceText}>{templateDetail?.price || 10}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.uploadContainer}>
|
{/* 动态表单 */}
|
||||||
{previewImageUri ? (
|
<View style={styles.formContainer}>
|
||||||
<Image
|
{templateLoading ? (
|
||||||
source={previewImageUri}
|
<View style={styles.loadingContainer}>
|
||||||
style={styles.uploadedImage}
|
<Text style={styles.loadingText}>{t('generateVideo.loading') || '加载中...'}</Text>
|
||||||
contentFit="cover"
|
</View>
|
||||||
|
) : formSchema.startNodes && formSchema.startNodes.length > 0 ? (
|
||||||
|
<DynamicForm
|
||||||
|
ref={dynamicFormRef}
|
||||||
|
formSchema={formSchema}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
loading={loading}
|
||||||
|
onOpenDrawer={handleOpenDrawer}
|
||||||
|
points={templateDetail?.price}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.avatarPlaceholder}>
|
<View style={styles.emptyContainer}>
|
||||||
<View style={styles.avatarCircle} />
|
<Text style={styles.emptyText}>
|
||||||
|
{t('generateVideo.noFormFields') || '暂无可填写的表单项'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={styles.thumbnailContainer}>
|
|
||||||
<Image
|
|
||||||
source={templateDetail?.thumbnailUrl}
|
|
||||||
style={styles.thumbnail}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{/* 上传区域 */}
|
|
||||||
<Pressable
|
|
||||||
style={styles.uploadReferenceButton}
|
|
||||||
onPress={() => {
|
|
||||||
setDrawerVisible(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UploadIcon />
|
|
||||||
<Text style={styles.uploadReferenceText}>{t('generateVideo.uploadReference')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
{/* 描述输入区域 */}
|
|
||||||
<TextInput
|
|
||||||
style={styles.descriptionInput}
|
|
||||||
value={description}
|
|
||||||
onChangeText={setDescription}
|
|
||||||
placeholder={t('generateVideo.descriptionPlaceholder')}
|
|
||||||
placeholderTextColor="#8A8A8A"
|
|
||||||
multiline
|
|
||||||
numberOfLines={4}
|
|
||||||
textAlignVertical="top"
|
|
||||||
/>
|
|
||||||
{/* 底部生成按钮 */}
|
|
||||||
<View style={styles.generateButtonContainer}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleGenerate}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#9966FF', '#FF6699', '#FF9966']}
|
|
||||||
locations={[0.0015, 0.4985, 0.9956]}
|
|
||||||
start={{ x: 1, y: 0 }}
|
|
||||||
end={{ x: 0, y: 0 }}
|
|
||||||
style={[styles.generateButton, loading && styles.generateButtonDisabled]}
|
|
||||||
>
|
|
||||||
<Text style={styles.generateButtonText}>{loading ? (t('generateVideo.generating') || '生成中...') : (t('generateVideo.generate') || '生成')}</Text>
|
|
||||||
<View style={styles.pointsBadge}>
|
|
||||||
<WhitePointsIcon />
|
|
||||||
<Text style={styles.pointsText}>{templateDetail?.price || 10}</Text>
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* 图片上传抽屉 */}
|
||||||
<UploadReferenceImageDrawer
|
<UploadReferenceImageDrawer
|
||||||
visible={drawerVisible}
|
visible={drawerVisible}
|
||||||
onClose={() => setDrawerVisible(false)}
|
onClose={() => {
|
||||||
|
setDrawerVisible(false)
|
||||||
|
setCurrentNodeId(null)
|
||||||
|
}}
|
||||||
onSelectImage={handleSelectImage}
|
onSelectImage={handleSelectImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 通知组件 */}
|
{/* 通知组件 */}
|
||||||
{showNotification && (
|
{showNotification && (
|
||||||
<View style={styles.notificationContainer}>
|
<View style={styles.notificationContainer}>
|
||||||
@@ -255,28 +208,6 @@ const styles = StyleSheet.create({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
generateButtonContainer: {
|
|
||||||
marginTop: 'auto',
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingBottom: Platform.select({
|
|
||||||
ios: 10,
|
|
||||||
android: 10,
|
|
||||||
default: 10,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingTop: 16,
|
|
||||||
backgroundColor: '#1C1E20',
|
|
||||||
borderTopLeftRadius: 20,
|
|
||||||
borderTopRightRadius: 20,
|
|
||||||
gap: 8,
|
|
||||||
minHeight: Platform.select({
|
|
||||||
ios: screenHeight - 100,
|
|
||||||
android: screenHeight - 100,
|
|
||||||
default: screenHeight - 60,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -294,116 +225,94 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
titleSection: {
|
content: {
|
||||||
paddingHorizontal:4,
|
paddingHorizontal: 12,
|
||||||
marginBottom: 11,
|
paddingTop: 16,
|
||||||
|
backgroundColor: '#1C1E20',
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
gap: 16,
|
||||||
|
minHeight: Platform.select({
|
||||||
|
ios: screenHeight - 100,
|
||||||
|
android: screenHeight - 100,
|
||||||
|
default: screenHeight - 60,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
title: {
|
// 模板预览卡片样式
|
||||||
|
templatePreviewCard: {
|
||||||
|
backgroundColor: '#262A31',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2F3134',
|
||||||
|
},
|
||||||
|
templateThumbnailContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
templateThumbnail: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#1C1E20',
|
||||||
|
},
|
||||||
|
templateInfo: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
templateTitle: {
|
||||||
color: '#F5F5F5',
|
color: '#F5F5F5',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginBottom: 5,
|
lineHeight: 22,
|
||||||
marginLeft: -4,
|
|
||||||
},
|
},
|
||||||
subtitle: {
|
templateDescription: {
|
||||||
color: '#CCCCCC',
|
color: '#ABABAB',
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
priceTag: {
|
||||||
uploadContainer: {
|
flexDirection: 'row',
|
||||||
height: 140,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: '#262A31',
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'flex-end',
|
||||||
},
|
|
||||||
thumbnailContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 8,
|
|
||||||
bottom: 5,
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: 'hidden',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: '#FFFFFF',
|
|
||||||
backgroundColor: '#090A0B',
|
|
||||||
},
|
|
||||||
thumbnail: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
avatarPlaceholder: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: '#2F3134',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
avatarCircle: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: '#4A4C4F',
|
|
||||||
},
|
|
||||||
uploadedImage: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
uploadReferenceButton: {
|
|
||||||
height: 110,
|
|
||||||
backgroundColor: '#262A31',
|
|
||||||
borderRadius: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 6,
|
gap: 6,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#2F3134',
|
||||||
},
|
},
|
||||||
uploadReferenceText: {
|
priceText: {
|
||||||
color: '#F5F5F5',
|
color: '#FFCF00',
|
||||||
fontSize: 12,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
descriptionInput: {
|
// 表单容器样式
|
||||||
minHeight: 150,
|
formContainer: {
|
||||||
backgroundColor: '#262A31',
|
gap: 12,
|
||||||
borderRadius: 12,
|
|
||||||
padding: 12,
|
|
||||||
color: '#F5F5F5',
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 20,
|
|
||||||
...Platform.select({
|
|
||||||
android: {
|
|
||||||
textAlignVertical: 'top',
|
|
||||||
},
|
},
|
||||||
}),
|
loadingContainer: {
|
||||||
},
|
|
||||||
generateButton: {
|
|
||||||
width: '100%',
|
|
||||||
height: 48,
|
|
||||||
backgroundColor: 'red',
|
|
||||||
borderRadius: 12,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 8,
|
padding: 40,
|
||||||
},
|
},
|
||||||
generateButtonText: {
|
loadingText: {
|
||||||
color: '#F5F5F5',
|
color: '#8A8A8A',
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
pointsBadge: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
pointsText: {
|
|
||||||
color: '#f5f5f5',
|
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '500',
|
|
||||||
},
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 40,
|
||||||
|
backgroundColor: '#262A31',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#8A8A8A',
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
// 通知容器样式
|
||||||
notificationContainer: {
|
notificationContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: Platform.select({
|
top: Platform.select({
|
||||||
@@ -419,8 +328,5 @@ const styles = StyleSheet.create({
|
|||||||
notification: {
|
notification: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
generateButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ export default function TemplateDetailScreen() {
|
|||||||
formSchema={formSchema}
|
formSchema={formSchema}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
onOpenDrawer={handleOpenDrawer}
|
onOpenDrawer={handleOpenDrawer}
|
||||||
|
points={templateDetail?.price}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -31,17 +31,37 @@ export interface StartNode {
|
|||||||
text?: string
|
text?: string
|
||||||
label?: string
|
label?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
usage?: {
|
||||||
|
unit: string
|
||||||
|
price: number
|
||||||
|
price_desc: string
|
||||||
|
}
|
||||||
output?: {
|
output?: {
|
||||||
texts?: string[]
|
texts?: string[]
|
||||||
images?: Array<{ url: string }>
|
images?: Array<{ url: string }>
|
||||||
selections?: string
|
selections?: string
|
||||||
|
videos?: Array<{ url: string }>
|
||||||
|
usage?: {
|
||||||
|
unit: string
|
||||||
|
price: number
|
||||||
|
price_desc: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
actionData?: {
|
actionData?: {
|
||||||
options?: SelectOption[]
|
options?: SelectOption[]
|
||||||
required?: boolean
|
required?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
allowMultiple?: boolean
|
allowMultiple?: boolean
|
||||||
|
prompt?: string
|
||||||
|
duration?: string
|
||||||
|
resolution?: string
|
||||||
|
aspectRatio?: string
|
||||||
|
selectedModel?: any
|
||||||
|
advancedParams?: any
|
||||||
}
|
}
|
||||||
|
autoPlay?: boolean
|
||||||
|
controls?: boolean
|
||||||
|
flowType?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +78,11 @@ interface DynamicFormProps {
|
|||||||
onSubmit: (data: Record<string, string>) => Promise<{ generationId?: string; error?: any }>
|
onSubmit: (data: Record<string, string>) => Promise<{ generationId?: string; error?: any }>
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
onOpenDrawer?: (nodeId: string) => void
|
onOpenDrawer?: (nodeId: string) => void
|
||||||
|
points?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
||||||
function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer }, ref) {
|
function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points }, ref) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const startNodes = formSchema.startNodes || []
|
const startNodes = formSchema.startNodes || []
|
||||||
|
|
||||||
@@ -70,10 +91,20 @@ export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
|||||||
const initialData: Record<string, string> = {}
|
const initialData: Record<string, string> = {}
|
||||||
startNodes.forEach((node) => {
|
startNodes.forEach((node) => {
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
initialData[node.id] = node.data?.text || ''
|
// Get default value from data.text or output.texts[0]
|
||||||
|
const texts = node.data?.output?.texts
|
||||||
|
initialData[node.id] = texts?.[0] || node.data?.text || ''
|
||||||
} else if (node.type === 'select') {
|
} else if (node.type === 'select') {
|
||||||
// Get default value from output.selections
|
// Get default value from output.selections
|
||||||
initialData[node.id] = node.data?.output?.selections || ''
|
initialData[node.id] = node.data?.output?.selections || ''
|
||||||
|
} else if (node.type === 'image') {
|
||||||
|
// Get default value from output.images
|
||||||
|
const images = node.data?.output?.images
|
||||||
|
initialData[node.id] = images?.[0]?.url || ''
|
||||||
|
} else if (node.type === 'video') {
|
||||||
|
// Get default value from output.videos
|
||||||
|
const videos = node.data?.output?.videos
|
||||||
|
initialData[node.id] = videos?.[0]?.url || ''
|
||||||
} else {
|
} else {
|
||||||
initialData[node.id] = ''
|
initialData[node.id] = ''
|
||||||
}
|
}
|
||||||
@@ -83,7 +114,23 @@ export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
|||||||
|
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||||
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
|
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
|
||||||
const [previewImages, setPreviewImages] = useState<Record<string, string>>({})
|
const [previewImages, setPreviewImages] = useState<Record<string, string>>(() => {
|
||||||
|
const initialPreviews: Record<string, string> = {}
|
||||||
|
startNodes.forEach((node) => {
|
||||||
|
if (node.type === 'image') {
|
||||||
|
const images = node.data?.output?.images
|
||||||
|
if (images?.[0]?.url) {
|
||||||
|
initialPreviews[node.id] = images[0].url
|
||||||
|
}
|
||||||
|
} else if (node.type === 'video') {
|
||||||
|
const videos = node.data?.output?.videos
|
||||||
|
if (videos?.[0]?.url) {
|
||||||
|
initialPreviews[node.id] = videos[0].url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return initialPreviews
|
||||||
|
})
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
const updateFormData = useCallback((nodeId: string, value: string) => {
|
const updateFormData = useCallback((nodeId: string, value: string) => {
|
||||||
@@ -410,9 +457,16 @@ export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
|
<View style={styles.submitButtonContent}>
|
||||||
<Text style={styles.submitButtonText}>
|
<Text style={styles.submitButtonText}>
|
||||||
{t('dynamicForm.submit') || '提交'}
|
{t('dynamicForm.submit') || '提交'}
|
||||||
</Text>
|
</Text>
|
||||||
|
{points !== undefined && points > 0 && (
|
||||||
|
<View style={styles.pointsContainer}>
|
||||||
|
<Text style={styles.pointsText}>{points}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -499,11 +553,28 @@ const styles = StyleSheet.create({
|
|||||||
height: 56,
|
height: 56,
|
||||||
minHeight: 56,
|
minHeight: 56,
|
||||||
},
|
},
|
||||||
|
submitButtonContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
submitButtonText: {
|
submitButtonText: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
pointsContainer: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
pointsText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TextInput } from "react-native";
|
import { ActivityIndicator, TextInput } from "react-native";
|
||||||
import { authClient, setAuthToken, useSession } from "../../lib/auth";
|
import { authClient, setAuthToken, useSession } from "../../lib/auth";
|
||||||
|
import type { ApiError } from "../../lib/types";
|
||||||
import { Block } from "../ui";
|
import { Block } from "../ui";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import Text from "../ui/Text";
|
import Text from "../ui/Text";
|
||||||
@@ -29,15 +30,17 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
const isLogin = currentMode === "login";
|
const isLogin = currentMode === "login";
|
||||||
|
|
||||||
// 根据错误代码获取翻译后的错误信息
|
// 根据错误代码获取翻译后的错误信息
|
||||||
const getErrorMessage = (error: { message: string; code?: string }): string => {
|
const getErrorMessage = (error: ApiError): string => {
|
||||||
if (error.code) {
|
if (error.code) {
|
||||||
const translated = t(`authForm.errors.${error.code}`);
|
const key = `authForm.errors.${error.code}`;
|
||||||
// 如果翻译不存在,返回原始 message
|
const translated = t(key);
|
||||||
if (!translated.includes('authForm.errors')) {
|
// 如果翻译存在,返回翻译结果
|
||||||
|
if (translated !== key) {
|
||||||
return translated;
|
return translated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return error.message;
|
// 返回 message 或默认错误信息
|
||||||
|
return error.message || t("authForm.errors.UNKNOWN_ERROR");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -56,33 +59,49 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLogin) {
|
if (isLogin) {
|
||||||
await signIn.username({ username, password }, {
|
const result = await signIn.username({ username, password }, {
|
||||||
onSuccess: async (ctx) => {
|
onSuccess: async (ctx) => {
|
||||||
const authToken = ctx.response.headers.get('set-auth-token')
|
const authToken = ctx.response.headers.get('set-auth-token')
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
setAuthToken(authToken)
|
await setAuthToken(authToken)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (ctx) => {
|
onError: (ctx) => {
|
||||||
setError(getErrorMessage(ctx.error));
|
setError(getErrorMessage(ctx.error));
|
||||||
console.error(`[LOGIN] username login error`, ctx)
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await signUp.email({ email, password, name: username }, {
|
|
||||||
onSuccess: async (ctx) => {
|
|
||||||
const authToken = ctx.response.headers.get('set-auth-token')
|
|
||||||
if (authToken) {
|
|
||||||
setAuthToken(authToken)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (ctx) => {
|
|
||||||
setError(getErrorMessage(ctx.error));
|
|
||||||
console.error(`[LOGIN] username login error`, ctx)
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 检查返回结果中是否有错误
|
||||||
|
if (result.error) {
|
||||||
|
setError(getErrorMessage(result.error));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 登录成功
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
const result = await signUp.email({ email, password, name: username }, {
|
||||||
|
onSuccess: async (ctx) => {
|
||||||
|
const authToken = ctx.response.headers.get('set-auth-token')
|
||||||
|
if (authToken) {
|
||||||
|
await setAuthToken(authToken)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (ctx) => {
|
||||||
|
setError(getErrorMessage(ctx.error));
|
||||||
|
console.error(`[REGISTER] email register error`, ctx)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查返回结果中是否有错误
|
||||||
|
if (result.error) {
|
||||||
|
setError(getErrorMessage(result.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册成功
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : (isLogin ? t("authForm.loginFailed") : t("authForm.registerFailed"));
|
const msg = e instanceof Error ? e.message : (isLogin ? t("authForm.loginFailed") : t("authForm.registerFailed"));
|
||||||
setError(msg);
|
setError(msg);
|
||||||
@@ -139,7 +158,9 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Text className="text-red-500 text-sm mb-4 text-center">{error}</Text>
|
<Text style={{ color: '#ef4444' }} className="text-sm mb-4 text-center">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
"select": "选择",
|
"select": "选择",
|
||||||
"selectPlaceholder": "请选择",
|
"selectPlaceholder": "请选择",
|
||||||
"noFields": "暂无可填写的表单项",
|
"noFields": "暂无可填写的表单项",
|
||||||
"submit": "提交"
|
"submit": "生成视频"
|
||||||
},
|
},
|
||||||
"pointsDrawer": {
|
"pointsDrawer": {
|
||||||
"title": "我的积分",
|
"title": "我的积分",
|
||||||
|
|||||||
Reference in New Issue
Block a user