374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
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, useCallback, useMemo } 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 = useCallback((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 = useCallback((fieldName: string, value: any) => {
|
||
const newFormData = { ...formData, [fieldName]: value };
|
||
setFormData(newFormData);
|
||
|
||
// 只验证当前字段
|
||
const field = schema.fields.find(f => f.name === fieldName);
|
||
if (!field) return;
|
||
|
||
const fieldError = validateField(field, value);
|
||
const newErrors = { ...errors };
|
||
|
||
if (fieldError) {
|
||
newErrors[fieldName] = fieldError;
|
||
} else {
|
||
delete newErrors[fieldName];
|
||
}
|
||
|
||
setErrors(newErrors);
|
||
|
||
// 通知父组件数据变化
|
||
if (onDataChange) {
|
||
const isValid = Object.keys(newErrors).length === 0;
|
||
onDataChange(newFormData, isValid);
|
||
}
|
||
}, [formData, schema.fields, errors, validateField, onDataChange]);
|
||
|
||
// 渲染表单字段
|
||
const renderField = useCallback((field: FormFieldSchema) => {
|
||
const value = formData[field.name];
|
||
const error = errors[field.name];
|
||
const onChange = (newValue: any) => updateField(field.name, newValue);
|
||
|
||
switch (field.type) {
|
||
case 'text':
|
||
case 'textarea':
|
||
return <TextInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||
|
||
case 'number':
|
||
return <NumberInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||
|
||
case 'select':
|
||
return <SelectInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||
|
||
case 'image':
|
||
return <ImageUploadField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||
|
||
case 'color':
|
||
return <ColorInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||
|
||
default:
|
||
console.warn(`不支持的表单字段类型: ${field.type}`);
|
||
return null;
|
||
}
|
||
}, [formData, errors, updateField]);
|
||
|
||
// 显示加载状态
|
||
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',
|
||
},
|
||
});
|