feat: 对接 generateVideo 页面后端接口

- 新增 uploadFile 工具函数用于图片上传
- 更新 useTemplateActions hook 使用 handleError 统一错误处理
- 实现 generateVideo 页面视频生成功能
  - 根据 formSchema.startNodes 动态构建请求数据
  - 支持图片和文本输入
  - 添加 loading 状态和错误提示
  - 生成成功后显示通知并返回

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos 2026-01-16 12:33:05 +08:00
parent 4d1e901032
commit 755a374b67
3 changed files with 126 additions and 39 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
View,
Text,
@ -21,35 +21,44 @@ import { LinearGradient } from 'expo-linear-gradient'
import { LeftArrowIcon, UploadIcon, WhitePointsIcon } from '@/components/icon'
import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer'
import { StartGeneratingNotification } from '@/components/ui'
import { useTemplateActions } from '@/hooks/use-template-actions'
import { uploadFile } from '@/lib/uploadFile'
import Toast from '@/components/ui/Toast'
const { height: screenHeight } = Dimensions.get('window')
interface TemplateData {
id: number
id: string
videoUrl: any
thumbnailUrl: any
title: string
duration: string
price?: number
formSchema?: {
startNodes?: Array<{ id: string; type: string }>
}
}
export default function GenerateVideoScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const { runTemplate, loading } = useTemplateActions()
const [description, setDescription] = useState('')
const [uploadedImage, setUploadedImage] = useState<any>(null)
const [uploadedImageUrl, setUploadedImageUrl] = useState('')
const [previewImageUri, setPreviewImageUri] = useState('')
const [templateData, setTemplateData] = useState<TemplateData | null>(null)
const [drawerVisible, setDrawerVisible] = useState(false)
const [showNotification, setShowNotification] = useState(false)
useEffect(() => {
if (params.template && typeof params.template === 'string') {
try {
const parsed = JSON.parse(params.template) as TemplateData
setTemplateData(parsed)
// 使用缩略图作为上传的图片
if (parsed.thumbnailUrl) {
setUploadedImage(parsed.thumbnailUrl)
setPreviewImageUri(parsed.thumbnailUrl)
}
} catch (error) {
console.error('Failed to parse template data:', error)
@ -57,6 +66,61 @@ export default function GenerateVideoScreen() {
}
}, [params.template])
const startNodes = useMemo(() => templateData?.formSchema?.startNodes || [], [templateData])
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 handleGenerate = useCallback(async () => {
if (!templateData) return
if (hasImageNode && !uploadedImageUrl) {
Toast.show({ title: t('generateVideo.pleaseUploadImage') || '请上传参考图片' })
return
}
Toast.showLoading()
try {
const data: Record<string, string> = {}
startNodes.forEach((node) => {
data[node.id] = node.type === 'text' ? description : uploadedImageUrl
})
const { generationId, error } = await runTemplate({
templateId: templateData.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') || '生成失败' })
}
}, [templateData, uploadedImageUrl, description, runTemplate, router, t, hasImageNode, startNodes])
return (
<SafeAreaView
style={styles.container}
@ -100,9 +164,9 @@ export default function GenerateVideoScreen() {
</View>
<View style={styles.uploadContainer}>
{uploadedImage ? (
{previewImageUri ? (
<Image
source={uploadedImage}
source={previewImageUri}
style={styles.uploadedImage}
contentFit="cover"
/>
@ -143,25 +207,20 @@ export default function GenerateVideoScreen() {
{/* 底部生成按钮 */}
<View style={styles.generateButtonContainer}>
<Pressable
onPress={() => {
setShowNotification(true)
// 3秒后自动隐藏通知
setTimeout(() => {
setShowNotification(false)
}, 3000)
}}
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}
style={[styles.generateButton, loading && styles.generateButtonDisabled]}
>
<Text style={styles.generateButtonText}>{t('generateVideo.generate')}</Text>
<Text style={styles.generateButtonText}>{loading ? (t('generateVideo.generating') || '生成中...') : (t('generateVideo.generate') || '生成')}</Text>
<View style={styles.pointsBadge}>
<WhitePointsIcon />
<Text style={styles.pointsText}>10</Text>
<Text style={styles.pointsText}>{templateData?.price || 10}</Text>
</View>
</LinearGradient>
</Pressable>
@ -172,9 +231,7 @@ export default function GenerateVideoScreen() {
<UploadReferenceImageDrawer
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
onSelectImage={(imageUri) => {
setUploadedImage(imageUri)
}}
onSelectImage={handleSelectImage}
/>
{/* 通知组件 */}
{showNotification && (
@ -373,5 +430,8 @@ const styles = StyleSheet.create({
notification: {
width: '100%',
},
generateButtonDisabled: {
opacity: 0.6,
},
})

View File

@ -1,31 +1,33 @@
import { OWNER_ID } from "@/lib/auth"
import { ApiError } from "@/lib/types"
import { root } from '@repo/core'
import { TemplateController, type RunTemplateInput } from "@repo/sdk"
import { useState } from "react"
import { type RunTemplateInput, TemplateController } from '@repo/sdk'
import { useCallback, useState } from 'react'
import { type ApiError } from '@/lib/types'
import { handleError } from './use-error'
export const useTemplateActions = () => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const runTemplate = async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => {
const runTemplate = useCallback(async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => {
setLoading(true)
setError(null)
try {
const template = root.get(TemplateController)
const result = await template.run({
templateId: params.templateId,
data: params.data || {},
})
setLoading(false)
return { generationId: result.generationId }
} catch (e) {
setError(e as ApiError)
setLoading(false)
return { error: e as ApiError }
const template = root.get(TemplateController)
const { data, error } = await handleError(async () => await template.run({
templateId: params.templateId,
data: params.data || {},
}))
setLoading(false)
if (error) {
setError(error)
return { error }
}
}
return { generationId: data?.generationId, error: null }
}, [])
return {
loading,

25
lib/uploadFile.ts Normal file
View File

@ -0,0 +1,25 @@
import { root } from '@repo/core'
import { FileController } from '@repo/sdk'
import { Platform } from 'react-native'
import { handleError } from '@/hooks/use-error'
export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise<string> {
const { uri, mimeType, fileName } = params
const file = {
name: fileName || 'uploaded_file.jpg',
type: mimeType || 'image/jpeg',
uri: Platform.OS === 'android' ? uri : uri.replace('file://', ''),
}
const formData = new FormData()
formData.append('file', file as any)
const fileController = root.get(FileController)
const { data, error } = await handleError(async () => await fileController.uploadS3(formData))
if (error || !data?.data) {
throw error || new Error('上传失败')
}
return data.data
}