expo-popcore-app/components/DynamicForm.tsx

667 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useImperativeHandle, forwardRef, useRef } from 'react'
import {
View,
TextInput,
Pressable,
StyleSheet,
Platform,
ActivityIndicator,
} from 'react-native'
import { Image } from 'expo-image'
import { useTranslation } from 'react-i18next'
import * as ImagePicker from 'expo-image-picker'
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'
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
/** 当外层已有滚动视图时,禁用内部滚动 */
disableScroll?: boolean
}
export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points, disableScroll = false }, 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 [asset] = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' })
if (!asset) {
console.warn('No asset selected')
return
}
if (asset?.uri) {
Toast.showLoading({ title: '文件传输中...', duration: 0 })
console.log('[DynamicForm] 选择图片成功:', asset)
// 上传图片
const url = await uploadFile({ uri: asset.uri })
console.log('[DynamicForm] 上传成功URL:', url)
updateFormData(nodeId, url)
setPreviewImages((prev) => ({ ...prev, [nodeId]: url }))
}
} catch (error: any) {
// 用户取消选择不显示错误
if (error?.message === '未选择任何图片') {
return
}
console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack })
Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试')
} finally {
Toast.hideLoading()
}
}, [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>
)
}
const formContent = (
<>
{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>
</>
)
// 当外层已有滚动视图时,不使用内部滚动
if (disableScroll) {
return (
<View style={styles.scrollContent}>
{formContent}
</View>
)
}
return (
<KeyboardAwareScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
bottomOffset={50}
>
{formContent}
</KeyboardAwareScrollView>
)
}
)
const styles = StyleSheet.create({
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',
},
})