diff --git a/app/(tabs)/video.tsx b/app/(tabs)/video.tsx
index 9dae9da..334f9ec 100644
--- a/app/(tabs)/video.tsx
+++ b/app/(tabs)/video.tsx
@@ -55,6 +55,14 @@ const ErrorState = () => (
)
+const EmptyState = () => (
+
+
+ 暂无视频模板
+
+
+)
+
const FooterLoading = () => (
@@ -171,19 +179,37 @@ export default function VideoScreen() {
index,
}), [videoHeight])
- // 过滤掉视频类型的 item
+ // 过滤掉没有可用预览的 item
const filteredTemplates = templates.filter((item: TemplateDetail) => {
- // 检查所有可能的预览 URL,如果任何一个包含视频格式则过滤掉
- const hasVideoUrl = [
- item.webpHighPreviewUrl,
- item.webpPreviewUrl,
- item.previewUrl,
- ].some(url => url && isVideoUrl(url))
- return !hasVideoUrl
+ // 优先使用 WebP 格式(支持动画),回退到普通预览图
+ const displayUrl = item.webpHighPreviewUrl || item.webpPreviewUrl || item.previewUrl
+
+ // 只检查最终要显示的 URL 是否为视频格式
+ const isDisplayVideo = displayUrl && isVideoUrl(displayUrl)
+
+ // 有显示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
if (error && templates.length === 0) return
+ if (!loading && filteredTemplates.length === 0) return
return (
diff --git a/app/generateVideo.tsx b/app/generateVideo.tsx
index f86407c..a73e10d 100644
--- a/app/generateVideo.tsx
+++ b/app/generateVideo.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback, useMemo } from 'react'
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
View,
Text,
@@ -6,7 +6,6 @@ import {
ScrollView,
Dimensions,
Pressable,
- TextInput,
StatusBar as RNStatusBar,
Platform,
KeyboardAvoidingView,
@@ -16,14 +15,13 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter, useLocalSearchParams } from 'expo-router'
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 { StartGeneratingNotification } from '@/components/ui'
+import { DynamicForm, type DynamicFormRef, type FormSchema } from '@/components/DynamicForm'
import { useTemplateActions } from '@/hooks/use-template-actions'
import { useTemplateDetail } from '@/hooks/use-template-detail'
-import { uploadFile } from '@/lib/uploadFile'
import Toast from '@/components/ui/Toast'
const { height: screenHeight } = Dimensions.get('window')
@@ -35,11 +33,10 @@ export default function GenerateVideoScreen() {
const { runTemplate, loading } = useTemplateActions()
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 [showNotification, setShowNotification] = useState(false)
+ const [currentNodeId, setCurrentNodeId] = useState(null)
+ const dynamicFormRef = useRef(null)
useEffect(() => {
if (params.templateId && typeof params.templateId === 'string') {
@@ -47,80 +44,55 @@ export default function GenerateVideoScreen() {
}
}, [params.templateId, fetchTemplate])
- useEffect(() => {
- if (templateDetail) {
- if (templateDetail.thumbnailUrl) {
- setPreviewImageUri(templateDetail.thumbnailUrl)
- }
- }
- }, [templateDetail])
+ const formSchema = useMemo(() => ({
+ startNodes: templateDetail?.formSchema?.startNodes || []
+ }), [templateDetail])
- const startNodes = useMemo(() => templateDetail?.formSchema?.startNodes || [], [templateDetail])
- const hasImageNode = useMemo(() => startNodes.some((node) => node.type === 'image'), [startNodes])
-
- 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 handleOpenDrawer = useCallback((nodeId: string) => {
+ setCurrentNodeId(nodeId)
+ setDrawerVisible(true)
}, [])
- const handleGenerate = useCallback(async () => {
- if (!templateDetail) return
+ const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => {
+ if (!currentNodeId) return
- if (hasImageNode && !uploadedImageUrl) {
- Toast.show({ title: t('generateVideo.pleaseUploadImage') || '请上传参考图片' })
- return
+ if (dynamicFormRef.current) {
+ dynamicFormRef.current.updateFieldValue(currentNodeId, imageUri, imageUri)
+ }
+ setDrawerVisible(false)
+ setCurrentNodeId(null)
+ }, [currentNodeId])
+
+ const handleFormSubmit = useCallback(async (data: Record) => {
+ if (!templateDetail) return { error: { message: 'Template not found' } }
+
+ const result = await runTemplate({
+ templateId: templateDetail.id,
+ data,
+ })
+
+ if (result.generationId) {
+ setShowNotification(true)
+ setTimeout(() => {
+ setShowNotification(false)
+ router.back()
+ }, 3000)
}
- Toast.showLoading()
- try {
- const data: Record = {}
- startNodes.forEach((node) => {
- data[node.id] = node.type === 'text' ? description : uploadedImageUrl
- })
-
- const { generationId, error } = await runTemplate({
- templateId: templateDetail.id,
- data,
- })
-
- Toast.hideLoading()
-
- if (error) {
- Toast.show({ title: error.message || t('generateVideo.generateFailed') || '生成失败' })
- return
- }
-
- if (generationId) {
- setShowNotification(true)
- setTimeout(() => {
- setShowNotification(false)
- router.back()
- }, 3000)
- }
- } catch (error) {
- Toast.hideLoading()
- Toast.show({ title: t('generateVideo.generateFailed') || '生成失败' })
- }
- }, [templateDetail, uploadedImageUrl, description, runTemplate, router, t, hasImageNode, startNodes])
+ return result
+ }, [templateDetail, runTemplate, router])
return (
-
{Platform.OS === 'android' && (
-
)}
- {/* 顶部导航栏 */}
-
- router.back()}
- >
-
-
-
-
- {/* 标题区域 */}
-
- {templateDetail?.title}
-
- {templateDetail?.title }
-
-
-
-
- {previewImageUri ? (
-
- ) : (
-
-
-
- )}
-
-
-
-
- {/* 上传区域 */}
- {
- setDrawerVisible(true)
- }}
- >
-
- {t('generateVideo.uploadReference')}
-
- {/* 描述输入区域 */}
-
- {/* 底部生成按钮 */}
-
-
-
+ router.back()}
>
- {loading ? (t('generateVideo.generating') || '生成中...') : (t('generateVideo.generate') || '生成')}
-
-
- {templateDetail?.price || 10}
+
+
+
+
+ {/* 主要内容区域 */}
+
+ {/* 模板预览卡片 */}
+
+
+
+
+ {templateDetail?.title}
+ {templateDetail?.description}
+
-
-
-
-
+ {/* 价格标签 */}
+
+
+ {templateDetail?.price || 10}
+
+
+
+ {/* 动态表单 */}
+
+ {templateLoading ? (
+
+ {t('generateVideo.loading') || '加载中...'}
+
+ ) : formSchema.startNodes && formSchema.startNodes.length > 0 ? (
+
+ ) : (
+
+
+ {t('generateVideo.noFormFields') || '暂无可填写的表单项'}
+
+
+ )}
+
+
+
+ {/* 图片上传抽屉 */}
setDrawerVisible(false)}
+ onClose={() => {
+ setDrawerVisible(false)
+ setCurrentNodeId(null)
+ }}
onSelectImage={handleSelectImage}
/>
+
{/* 通知组件 */}
{showNotification && (
@@ -255,28 +208,6 @@ const styles = StyleSheet.create({
flexGrow: 1,
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: {
flexDirection: 'row',
alignItems: 'center',
@@ -294,116 +225,94 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
- titleSection: {
- paddingHorizontal:4,
- marginBottom: 11,
+ content: {
+ paddingHorizontal: 12,
+ 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',
fontSize: 16,
fontWeight: '600',
- marginBottom: 5,
- marginLeft: -4,
+ lineHeight: 22,
},
- subtitle: {
- color: '#CCCCCC',
- fontSize: 12,
+ templateDescription: {
+ color: '#ABABAB',
+ fontSize: 13,
+ lineHeight: 18,
},
-
- uploadContainer: {
- height: 140,
- borderRadius: 12,
- backgroundColor: '#262A31',
- overflow: 'hidden',
- position: 'relative',
+ priceTag: {
+ flexDirection: 'row',
alignItems: 'center',
- justifyContent: 'center',
- },
- 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',
+ justifyContent: 'flex-end',
gap: 6,
+ marginTop: 8,
+ paddingTop: 8,
+ borderTopWidth: 1,
+ borderTopColor: '#2F3134',
},
- uploadReferenceText: {
- color: '#F5F5F5',
- fontSize: 12,
- fontWeight: '500',
+ priceText: {
+ color: '#FFCF00',
+ fontSize: 16,
+ fontWeight: '600',
},
- descriptionInput: {
- minHeight: 150,
- backgroundColor: '#262A31',
- borderRadius: 12,
- padding: 12,
- color: '#F5F5F5',
- fontSize: 14,
- lineHeight: 20,
- ...Platform.select({
- android: {
- textAlignVertical: 'top',
- },
- }),
+ // 表单容器样式
+ formContainer: {
+ gap: 12,
},
- generateButton: {
- width: '100%',
- height: 48,
- backgroundColor: 'red',
- borderRadius: 12,
- flexDirection: 'row',
+ loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
- gap: 8,
+ padding: 40,
},
- generateButtonText: {
- color: '#F5F5F5',
- fontSize: 16,
- fontWeight: '500',
- },
- pointsBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- pointsText: {
- color: '#f5f5f5',
+ loadingText: {
+ color: '#8A8A8A',
fontSize: 14,
- fontWeight: '500',
},
+ emptyContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 40,
+ backgroundColor: '#262A31',
+ borderRadius: 12,
+ },
+ emptyText: {
+ color: '#8A8A8A',
+ fontSize: 14,
+ textAlign: 'center',
+ },
+ // 通知容器样式
notificationContainer: {
position: 'absolute',
top: Platform.select({
@@ -419,8 +328,5 @@ const styles = StyleSheet.create({
notification: {
width: '100%',
},
- generateButtonDisabled: {
- opacity: 0.6,
- },
})
diff --git a/app/templateDetail.tsx b/app/templateDetail.tsx
index 21d3130..aa5e58d 100644
--- a/app/templateDetail.tsx
+++ b/app/templateDetail.tsx
@@ -195,6 +195,7 @@ export default function TemplateDetailScreen() {
formSchema={formSchema}
onSubmit={handleFormSubmit}
onOpenDrawer={handleOpenDrawer}
+ points={templateDetail?.price}
/>
) : (
diff --git a/components/DynamicForm.tsx b/components/DynamicForm.tsx
index 4bf9f0d..6eae660 100644
--- a/components/DynamicForm.tsx
+++ b/components/DynamicForm.tsx
@@ -31,17 +31,37 @@ export interface StartNode {
text?: string
label?: string
description?: string
+ usage?: {
+ unit: string
+ price: number
+ price_desc: string
+ }
output?: {
texts?: string[]
images?: Array<{ url: string }>
selections?: string
+ videos?: Array<{ url: string }>
+ usage?: {
+ unit: string
+ price: number
+ price_desc: string
+ }
}
actionData?: {
options?: SelectOption[]
required?: boolean
placeholder?: string
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) => Promise<{ generationId?: string; error?: any }>
loading?: boolean
onOpenDrawer?: (nodeId: string) => void
+ points?: number
}
export const DynamicForm = forwardRef(
- function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer }, ref) {
+ function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points }, ref) {
const { t } = useTranslation()
const startNodes = formSchema.startNodes || []
@@ -70,10 +91,20 @@ export const DynamicForm = forwardRef(
const initialData: Record = {}
startNodes.forEach((node) => {
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') {
// Get default value from 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 {
initialData[node.id] = ''
}
@@ -83,7 +114,23 @@ export const DynamicForm = forwardRef(
const [drawerVisible, setDrawerVisible] = useState(false)
const [currentNodeId, setCurrentNodeId] = useState(null)
- const [previewImages, setPreviewImages] = useState>({})
+ const [previewImages, setPreviewImages] = useState>(() => {
+ const initialPreviews: Record = {}
+ 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>({})
const updateFormData = useCallback((nodeId: string, value: string) => {
@@ -410,9 +457,16 @@ export const DynamicForm = forwardRef(
{loading ? (
) : (
-
- {t('dynamicForm.submit') || '提交'}
-
+
+
+ {t('dynamicForm.submit') || '提交'}
+
+ {points !== undefined && points > 0 && (
+
+ {points}
+
+ )}
+
)}
@@ -499,11 +553,28 @@ const styles = StyleSheet.create({
height: 56,
minHeight: 56,
},
+ submitButtonContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ },
submitButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '500',
},
+ pointsContainer: {
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 10,
+ },
+ pointsText: {
+ color: '#FFFFFF',
+ fontSize: 12,
+ fontWeight: '600',
+ },
emptyContainer: {
flex: 1,
alignItems: 'center',
diff --git a/components/blocks/AuthForm.tsx b/components/blocks/AuthForm.tsx
index cecc403..db95343 100644
--- a/components/blocks/AuthForm.tsx
+++ b/components/blocks/AuthForm.tsx
@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TextInput } from "react-native";
import { authClient, setAuthToken, useSession } from "../../lib/auth";
+import type { ApiError } from "../../lib/types";
import { Block } from "../ui";
import { Button } from "../ui/button";
import Text from "../ui/Text";
@@ -29,15 +30,17 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
const isLogin = currentMode === "login";
// 根据错误代码获取翻译后的错误信息
- const getErrorMessage = (error: { message: string; code?: string }): string => {
+ const getErrorMessage = (error: ApiError): string => {
if (error.code) {
- const translated = t(`authForm.errors.${error.code}`);
- // 如果翻译不存在,返回原始 message
- if (!translated.includes('authForm.errors')) {
+ const key = `authForm.errors.${error.code}`;
+ const translated = t(key);
+ // 如果翻译存在,返回翻译结果
+ if (translated !== key) {
return translated;
}
}
- return error.message;
+ // 返回 message 或默认错误信息
+ return error.message || t("authForm.errors.UNKNOWN_ERROR");
};
const handleSubmit = async () => {
@@ -56,33 +59,49 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
try {
if (isLogin) {
- await signIn.username({ username, password }, {
+ const result = await signIn.username({ username, password }, {
onSuccess: async (ctx) => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
- setAuthToken(authToken)
+ await setAuthToken(authToken)
}
},
onError: (ctx) => {
setError(getErrorMessage(ctx.error));
- console.error(`[LOGIN] username login error`, ctx)
},
});
+
+ // 检查返回结果中是否有错误
+ if (result.error) {
+ setError(getErrorMessage(result.error));
+ return;
+ }
+
+ // 登录成功
+ onSuccess?.();
} else {
- await signUp.email({ email, password, name: username }, {
+ const result = await signUp.email({ email, password, name: username }, {
onSuccess: async (ctx) => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
- setAuthToken(authToken)
+ await setAuthToken(authToken)
}
},
onError: (ctx) => {
setError(getErrorMessage(ctx.error));
- console.error(`[LOGIN] username login error`, ctx)
+ console.error(`[REGISTER] email register error`, ctx)
},
});
+
+ // 检查返回结果中是否有错误
+ if (result.error) {
+ setError(getErrorMessage(result.error));
+ return;
+ }
+
+ // 注册成功
+ onSuccess?.();
}
- onSuccess?.();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : (isLogin ? t("authForm.loginFailed") : t("authForm.registerFailed"));
setError(msg);
@@ -139,7 +158,9 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
/>
{error ? (
- {error}
+
+ {error}
+
) : null}