651 lines
22 KiB
TypeScript
651 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 })
|
||
console.log('[DynamicForm] 选择图片成功:', imageUri)
|
||
|
||
// 上传图片
|
||
const url = await uploadFile({ uri: imageUri })
|
||
console.log('[DynamicForm] 上传成功,URL:', url)
|
||
updateFormData(nodeId, url)
|
||
setPreviewImages((prev) => ({ ...prev, [nodeId]: imageUri }))
|
||
} catch (error: any) {
|
||
// 用户取消选择不显示错误
|
||
if (error?.message === '未选择任何图片') {
|
||
return
|
||
}
|
||
console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack })
|
||
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',
|
||
},
|
||
})
|