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}