bw-expo-app/components/forms/dynamic-form.tsx

392 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { View, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { RunFormSchema, RunTemplateData, FormFieldSchema } from '@/lib/types/template-run';
import { TextInputField } from './form-fields/text-input';
import { NumberInputField } from './form-fields/number-input';
import { SelectInputField } from './form-fields/select-input';
import { ImageUploadField } from './form-fields/image-upload';
import { ColorInputField } from './form-fields/color-input';
import { useState, useEffect } from 'react';
import { validateFormSchema } from '@/lib/utils/form-schema-transformer';
interface DynamicFormProps {
schema: RunFormSchema;
initialData?: RunTemplateData;
onDataChange?: (data: RunTemplateData, isValid: boolean) => void;
onSubmit?: () => void;
}
export function DynamicForm({
schema,
initialData = {},
onDataChange,
onSubmit
}: DynamicFormProps) {
const [formData, setFormData] = useState<RunTemplateData>(initialData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isValidating, setIsValidating] = useState(false);
const [schemaError, setSchemaError] = useState<string | null>(null);
// 验证schema并初始化默认值
useEffect(() => {
setIsValidating(true);
setSchemaError(null);
try {
if (!validateFormSchema(schema)) {
setSchemaError('表单配置格式不正确');
return;
}
const defaultData: RunTemplateData = {};
schema.fields.forEach(field => {
if (field.defaultValue !== undefined) {
defaultData[field.name] = field.defaultValue;
}
});
const finalInitialData = { ...defaultData, ...initialData };
setFormData(finalInitialData);
if (onDataChange) {
onDataChange(finalInitialData, true);
}
} catch (error) {
console.error('表单初始化失败:', error);
setSchemaError('表单初始化失败');
} finally {
setIsValidating(false);
}
}, [schema, initialData]);
// 验证单个字段
const validateField = (field: FormFieldSchema, value: any): string | null => {
// 检查必填
if (field.required && (value === undefined || value === null || value === '')) {
return `${field.label} 是必填项`;
}
// 如果值为空且不是必填,则跳过其他验证
if (value === undefined || value === null || value === '') {
return null;
}
// 根据类型验证
switch (field.type) {
case 'text':
case 'textarea':
if (typeof value !== 'string') {
return `${field.label} 必须是文本`;
}
if (field.min && value.length < field.min) {
return `${field.label} 至少需要 ${field.min} 个字符`;
}
if (field.max && value.length > field.max) {
return `${field.label} 最多 ${field.max} 个字符`;
}
break;
case 'number':
const numValue = Number(value);
if (isNaN(numValue)) {
return `${field.label} 必须是数字`;
}
if (field.min !== undefined && numValue < field.min) {
return `${field.label} 不能小于 ${field.min}`;
}
if (field.max !== undefined && numValue > field.max) {
return `${field.label} 不能大于 ${field.max}`;
}
break;
case 'select':
const isValidOption = field.options?.some(option => option.value === value);
if (!isValidOption) {
return `${field.label} 选择了无效的选项`;
}
break;
case 'image':
if (typeof value !== 'string') {
return `${field.label} 必须是图片URL`;
}
if (value && !value.startsWith('http') && !value.startsWith('file://')) {
return `${field.label} 必须是有效的图片地址`;
}
break;
case 'color':
if (typeof value !== 'string') {
return `${field.label} 必须是颜色值`;
}
if (value && !/^#[0-9A-F]{6}$/i.test(value)) {
return `${field.label} 必须是有效的颜色值 (如 #FF0000)`;
}
break;
}
return null;
};
// 更新表单数据
const updateField = (fieldName: string, value: any) => {
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
// 实时验证字段
const field = schema.fields.find(f => f.name === fieldName);
if (field) {
const error = validateField(field, value);
setErrors(prev => ({
...prev,
[fieldName]: error || ''
}));
}
// 通知父组件数据变化
if (onDataChange) {
// 计算整个表单的验证状态
const allErrors: Record<string, string> = {};
schema.fields.forEach(item => {
const nextValue = item.name === fieldName ? value : newFormData[item.name];
const itemError = validateField(item, nextValue);
if (itemError) {
allErrors[item.name] = itemError;
}
});
if (field) {
const fieldError = validateField(field, value);
if (fieldError) {
allErrors[fieldName] = fieldError;
} else {
delete allErrors[fieldName];
}
}
const isValid = Object.keys(allErrors).length === 0;
setErrors(allErrors);
onDataChange(newFormData, isValid);
}
};
// 渲染表单字段
const renderField = (field: FormFieldSchema) => {
const commonProps = {
field,
value: formData[field.name],
onChange: (value: any) => updateField(field.name, value),
error: errors[field.name],
};
switch (field.type) {
case 'text':
case 'textarea':
return <TextInputField key={field.name} {...commonProps} />;
case 'number':
return <NumberInputField key={field.name} {...commonProps} />;
case 'select':
return <SelectInputField key={field.name} {...commonProps} />;
case 'image':
return <ImageUploadField key={field.name} {...commonProps} />;
case 'color':
return <ColorInputField key={field.name} {...commonProps} />;
default:
console.warn(`不支持的表单字段类型: ${field.type}`);
return null;
}
};
// 显示加载状态
if (isValidating) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#4ECDC4" />
<ThemedText style={styles.loadingText}>...</ThemedText>
</View>
);
}
// 显示schema错误
if (schemaError) {
return (
<View style={styles.errorContainer}>
<ThemedText style={styles.errorTitle}></ThemedText>
<ThemedText style={styles.errorText}>{schemaError}</ThemedText>
<ThemedText style={styles.errorHint}>
</ThemedText>
</View>
);
}
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
<ThemedView style={styles.content}>
{schema.fields.length === 0 ? (
<View style={styles.emptyContainer}>
<ThemedText style={styles.emptyIcon}>📋</ThemedText>
<ThemedText style={styles.emptyText}></ThemedText>
<ThemedText style={styles.emptyDescription}>
使
</ThemedText>
</View>
) : (
<>
<ThemedView style={styles.formHeader}>
<ThemedText style={styles.formTitle}></ThemedText>
<ThemedText style={styles.formDescription}>
</ThemedText>
</ThemedView>
{schema.fields.map(field => renderField(field))}
{Object.keys(errors).length > 0 && (
<ThemedView style={styles.errorSummary}>
<ThemedText style={styles.errorSummaryText}>
{Object.keys(errors).length}
</ThemedText>
{Object.entries(errors).map(([fieldName, error]) => (
<ThemedText key={fieldName} style={styles.errorItem}>
{error}
</ThemedText>
))}
</ThemedView>
)}
{Object.keys(errors).length === 0 && formData && Object.keys(formData).length > 0 && (
<ThemedView style={styles.successSummary}>
<ThemedText style={styles.successText}>
</ThemedText>
</ThemedView>
)}
</>
)}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 16,
},
// 加载状态样式
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
loadingText: {
fontSize: 16,
marginTop: 16,
opacity: 0.7,
},
// 错误状态样式
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
errorTitle: {
fontSize: 18,
fontWeight: '600',
color: '#FF3B30',
marginBottom: 8,
},
errorText: {
fontSize: 16,
textAlign: 'center',
opacity: 0.8,
marginBottom: 12,
},
errorHint: {
fontSize: 14,
textAlign: 'center',
opacity: 0.6,
},
// 空状态样式
emptyContainer: {
alignItems: 'center',
paddingVertical: 40,
},
emptyIcon: {
fontSize: 48,
marginBottom: 16,
},
emptyText: {
fontSize: 18,
fontWeight: '500',
marginBottom: 8,
opacity: 0.8,
},
emptyDescription: {
fontSize: 14,
textAlign: 'center',
opacity: 0.6,
lineHeight: 20,
},
// 表单头部样式
formHeader: {
marginBottom: 20,
},
formTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
formDescription: {
fontSize: 14,
opacity: 0.7,
lineHeight: 20,
},
// 错误总结样式
errorSummary: {
backgroundColor: 'rgba(255, 59, 48, 0.1)',
borderRadius: 12,
padding: 16,
marginTop: 20,
borderLeftWidth: 4,
borderLeftColor: '#FF3B30',
},
errorSummaryText: {
fontSize: 16,
fontWeight: '600',
color: '#FF3B30',
marginBottom: 8,
},
errorItem: {
fontSize: 14,
color: '#FF3B30',
marginBottom: 4,
opacity: 0.9,
},
// 成功状态样式
successSummary: {
backgroundColor: 'rgba(52, 199, 89, 0.1)',
borderRadius: 12,
padding: 16,
marginTop: 20,
borderLeftWidth: 4,
borderLeftColor: '#34C759',
},
successText: {
fontSize: 16,
fontWeight: '600',
color: '#34C759',
},
});