expo-popcore-app/components/DynamicForm.tsx

649 lines
22 KiB
TypeScript

import React, { useState, useCallback, useImperativeHandle, forwardRef, useRef } from 'react'
import {
View,
TextInput,
Pressable,
StyleSheet,
Platform,
ActivityIndicator,
ScrollView,
KeyboardAvoidingView,
} from 'react-native'
import { Image } from 'expo-image'
import { useTranslation } from 'react-i18next'
import { Button } from './ui/button'
import Text from './ui/Text'
import { uploadFile } from '@/lib/uploadFile'
import { imgPicker } from '@/lib/imgPicker'
import Toast from './ui/Toast'
export type NodeType = 'text' | 'image' | 'video' | 'select'
export interface SelectOption {
label: string
value: string
}
export interface StartNode {
id: string
type: NodeType
data?: {
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
}
}
export interface FormSchema {
startNodes?: StartNode[]
}
export interface DynamicFormRef {
updateFieldValue: (nodeId: string, value: string, previewUri?: string) => void
}
interface DynamicFormProps {
formSchema: FormSchema
onSubmit: (data: Record<string, string>) => Promise<{ generationId?: string; error?: any }>
loading?: boolean
onOpenDrawer?: (nodeId: string) => void
points?: number
}
export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points }, ref) {
const { t } = useTranslation()
const startNodes = formSchema.startNodes || []
// Initialize form state for each node
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initialData: Record<string, string> = {}
startNodes.forEach((node) => {
if (node.type === '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] = ''
}
})
return initialData
})
const [drawerVisible, setDrawerVisible] = useState(false)
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
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 updateFormData = useCallback((nodeId: string, value: string) => {
setFormData((prev) => ({ ...prev, [nodeId]: value }))
// Clear error for this field when user updates it
if (errors[nodeId]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[nodeId]
return newErrors
})
}
}, [errors])
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
updateFieldValue: (nodeId: string, value: string, previewUri?: string) => {
updateFormData(nodeId, value)
if (previewUri) {
setPreviewImages((prev) => ({ ...prev, [nodeId]: previewUri }))
}
},
}), [updateFormData])
const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => {
if (!currentNodeId) return
try {
const url = await uploadFile({ uri: imageUri, mimeType, fileName })
updateFormData(currentNodeId, url)
setPreviewImages((prev) => ({ ...prev, [currentNodeId]: imageUri }))
} catch (error) {
console.error('Upload failed:', error)
Toast.show({
title: t('dynamicForm.uploadFailed') || '上传失败,请重试'
})
} finally {
setDrawerVisible(false)
setCurrentNodeId(null)
}
}, [currentNodeId, t, updateFormData])
// 直接从相册选择图片并上传
const handlePickAndUploadImage = useCallback(async (nodeId: string) => {
try {
const [imageUri] = await imgPicker({ maxImages: 1 })
// 上传图片
const url = await uploadFile({ uri: imageUri })
updateFormData(nodeId, url)
setPreviewImages((prev) => ({ ...prev, [nodeId]: imageUri }))
} catch (error: any) {
// 用户取消选择不显示错误
if (error?.message === '未选择任何图片') {
return
}
console.error('Pick and upload failed:', error)
Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试')
}
}, [t, updateFormData])
const handleSelectVideo = useCallback(async (videoUri: string, mimeType?: string, fileName?: string) => {
if (!currentNodeId) return
try {
const url = await uploadFile({ uri: videoUri, mimeType, fileName })
updateFormData(currentNodeId, url)
setPreviewImages((prev) => ({ ...prev, [currentNodeId]: videoUri }))
} catch (error) {
console.error('Upload failed:', error)
Toast.show({
title: t('dynamicForm.uploadFailed') || '上传失败,请重试'
})
} finally {
setDrawerVisible(false)
setCurrentNodeId(null)
}
}, [currentNodeId, t, updateFormData])
const validateForm = useCallback(() => {
const newErrors: Record<string, string> = {}
startNodes.forEach((node) => {
const value = formData[node.id]
if (!value || value.trim() === '') {
const label = node.data?.label || t('dynamicForm.field') || '字段'
newErrors[node.id] = `${label}${t('dynamicForm.required') || '不能为空'}`
}
})
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [formData, startNodes, t])
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
Toast.show({
title: t('dynamicForm.fillRequiredFields') || '请填写所有必填项'
})
return
}
Toast.showLoading()
try {
const result = await onSubmit(formData)
Toast.hideLoading()
if (result.error) {
Toast.show({
title: result.error.message || t('dynamicForm.submitFailed') || '提交失败'
})
}
} catch (error) {
Toast.hideLoading()
Toast.show({
title: t('dynamicForm.submitFailed') || '提交失败'
})
}
}, [formData, onSubmit, validateForm, t])
const renderTextField = (node: StartNode) => {
const value = formData[node.id] || ''
const error = errors[node.id]
const placeholder = node.data?.description || t('dynamicForm.enterText') || '请输入文本'
return (
<View key={node.id} style={styles.fieldContainer}>
{node.data?.label && (
<Text style={styles.label}>
{node.data.label}
<Text style={styles.required}> *</Text>
</Text>
)}
<TextInput
testID={`text-input-${node.id}`}
style={[styles.textInput, error && styles.textInputError]}
value={value}
onChangeText={(text) => updateFormData(node.id, text)}
placeholder={placeholder}
placeholderTextColor="#8A8A8A"
multiline
numberOfLines={4}
textAlignVertical="top"
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)
}
const renderImageField = (node: StartNode) => {
const value = formData[node.id]
const previewUri = previewImages[node.id]
const error = errors[node.id]
const label = node.data?.label || t('dynamicForm.image') || '图片'
return (
<View key={node.id} style={styles.fieldContainer}>
<Text style={styles.label}>
{label}
<Text style={styles.required}> *</Text>
</Text>
<Pressable
style={styles.uploadButton}
onPress={() => handlePickAndUploadImage(node.id)}
>
{previewUri ? (
<Image
source={previewUri}
style={styles.previewImage}
contentFit="cover"
/>
) : (
<View style={styles.uploadPlaceholder}>
<Text style={styles.uploadText}>
{t('dynamicForm.uploadImage') || '点击上传图片'}
</Text>
</View>
)}
</Pressable>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)
}
const renderVideoField = (node: StartNode) => {
const value = formData[node.id]
const previewUri = previewImages[node.id]
const error = errors[node.id]
const label = node.data?.label || t('dynamicForm.video') || '视频'
return (
<View key={node.id} style={styles.fieldContainer}>
<Text style={styles.label}>
{label}
<Text style={styles.required}> *</Text>
</Text>
<Pressable
style={styles.uploadButton}
onPress={() => {
setCurrentNodeId(node.id)
if (onOpenDrawer) {
onOpenDrawer(node.id)
} else {
setDrawerVisible(true)
}
}}
>
{previewUri ? (
<View style={styles.videoPreview}>
<Text style={styles.uploadText}>
{t('dynamicForm.videoUploaded') || '视频已上传'}
</Text>
</View>
) : (
<View style={styles.uploadPlaceholder}>
<Text style={styles.uploadText}>
{t('dynamicForm.uploadVideo') || '点击上传视频'}
</Text>
</View>
)}
</Pressable>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)
}
const renderSelectField = (node: StartNode) => {
const value = formData[node.id]
const error = errors[node.id]
const label = node.data?.label || t('dynamicForm.select') || '选择'
const options = node.data?.actionData?.options || []
const placeholder = node.data?.actionData?.placeholder || t('dynamicForm.selectPlaceholder') || '请选择'
const allowMultiple = node.data?.actionData?.allowMultiple || false
return (
<View key={node.id} style={styles.fieldContainer}>
<Text style={styles.label}>
{label}
<Text style={styles.required}> *</Text>
</Text>
{allowMultiple ? (
// Multi-select: render as a list of checkboxes
<View style={styles.optionsContainer}>
{options.map((option, index) => {
const isSelected = value === option.value
return (
<Pressable
key={index}
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
onPress={() => updateFormData(node.id, option.value)}
>
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
) : (
// Single select: render as a list of radio buttons
<View style={styles.optionsContainer}>
{options.map((option, index) => {
const isSelected = value === option.value
return (
<Pressable
key={index}
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
onPress={() => updateFormData(node.id, option.value)}
>
<View style={styles.radioContainer}>
<View style={[styles.radioCircle, isSelected && styles.radioCircleSelected]}>
{isSelected && <View style={styles.radioDot} />}
</View>
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
{option.label}
</Text>
</View>
</Pressable>
)
})}
</View>
)}
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)
}
const renderField = (node: StartNode) => {
switch (node.type) {
case 'text':
return renderTextField(node)
case 'image':
return renderImageField(node)
case 'video':
return renderVideoField(node)
case 'select':
return renderSelectField(node)
default:
return null
}
}
if (startNodes.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{t('dynamicForm.noFields') || '暂无可填写的表单项'}
</Text>
</View>
)
}
return (
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={0}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{startNodes.map(renderField)}
<View style={styles.submitButtonContainer}>
<Button
testID="submit-button"
variant="gradient"
onPress={handleSubmit}
disabled={loading}
style={styles.submitButton}
className="px-0"
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<View style={styles.submitButtonContent}>
<Text style={styles.submitButtonText}>
{t('dynamicForm.submit') || '提交'}
</Text>
{points !== undefined && points > 0 && (
<View style={styles.pointsContainer}>
<Text style={styles.pointsText}>{points}</Text>
</View>
)}
</View>
)}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
)
const styles = StyleSheet.create({
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: 16,
gap: 20,
},
fieldContainer: {
gap: 8,
},
label: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
},
required: {
color: '#FF6699',
},
textInput: {
minHeight: 100,
backgroundColor: '#262A31',
borderRadius: 12,
padding: 12,
color: '#F5F5F5',
fontSize: 14,
lineHeight: 20,
borderWidth: 1,
borderColor: 'transparent',
},
textInputError: {
borderColor: '#FF6699',
},
uploadButton: {
height: 140,
backgroundColor: '#262A31',
borderRadius: 12,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
uploadPlaceholder: {
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
previewImage: {
width: '100%',
height: '100%',
},
videoPreview: {
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
uploadText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
},
errorText: {
color: '#FF6699',
fontSize: 12,
marginTop: 4,
},
submitButtonContainer: {
marginTop: 12,
paddingTop: 12,
},
submitButton: {
width: '100%',
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',
justifyContent: 'center',
padding: 20,
},
emptyText: {
color: '#8A8A8A',
fontSize: 14,
textAlign: 'center',
},
optionsContainer: {
gap: 8,
},
optionButton: {
backgroundColor: '#262A31',
borderRadius: 12,
padding: 16,
borderWidth: 1,
borderColor: 'transparent',
},
optionButtonSelected: {
backgroundColor: '#FF6699',
borderColor: '#FF6699',
},
optionText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
},
optionTextSelected: {
color: '#FFFFFF',
},
radioContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
radioCircle: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: '#8A8A8A',
alignItems: 'center',
justifyContent: 'center',
},
radioCircleSelected: {
borderColor: '#FF6699',
backgroundColor: '#FF6699',
},
radioDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#FFFFFF',
},
})