636 lines
21 KiB
TypeScript
636 lines
21 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 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 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={() => {
|
|
setCurrentNodeId(node.id)
|
|
if (onOpenDrawer) {
|
|
onOpenDrawer(node.id)
|
|
} else {
|
|
setDrawerVisible(true)
|
|
}
|
|
}}
|
|
>
|
|
{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',
|
|
},
|
|
})
|