✨ feat: 性能优化与余额扣费系统集成
主要更新: - 🚀 模板详情页性能优化:添加缓存机制、图片预加载、请求中断控制 - 💰 集成余额检查与扣费功能到表单提交流程 - 🔄 添加错误重试机制,提升用户体验 - 🎨 重构动态表单字段为独立组件 (DynamicFormField) - 🔧 修复类型错误,优化样式结构 技术细节: - 实现5分钟缓存策略减少API调用 - 使用 AbortController 管理请求生命周期 - React.memo 优化列表项渲染性能 - 图片预缓存提升加载体验 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
391
components/forms/dynamic-form-field.tsx
Normal file
391
components/forms/dynamic-form-field.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { TemplateGraphNode } from '@/lib/types/template';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface DynamicFormFieldProps {
|
||||
node: TemplateGraphNode;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DynamicFormField({ node, value, onChange, error }: DynamicFormFieldProps) {
|
||||
const { type, data } = node;
|
||||
const { label, description, actionData } = data;
|
||||
|
||||
const renderField = () => {
|
||||
switch (type) {
|
||||
case 'select':
|
||||
return renderSelectField();
|
||||
case 'text':
|
||||
return renderTextField();
|
||||
case 'image':
|
||||
return renderImageField();
|
||||
case 'video':
|
||||
return renderVideoField();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderSelectField = () => {
|
||||
const options = actionData?.options || [];
|
||||
const allowMultiple = actionData?.allowMultiple || false;
|
||||
const placeholder = actionData?.placeholder || '请选择';
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
if (allowMultiple) {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
const newValues = currentValues.includes(optionValue)
|
||||
? currentValues.filter((v: string) => v !== optionValue)
|
||||
: [...currentValues, optionValue];
|
||||
onChange(newValues);
|
||||
} else {
|
||||
onChange(optionValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (optionValue: string) => {
|
||||
if (allowMultiple) {
|
||||
return Array.isArray(value) && value.includes(optionValue);
|
||||
}
|
||||
return value === optionValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<View style={styles.selectOptions}>
|
||||
{options.map((option: any, index: number) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.selectOption,
|
||||
isSelected(option.value) && styles.selectOptionSelected,
|
||||
error && styles.selectOptionError,
|
||||
]}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.selectOptionText,
|
||||
isSelected(option.value) && styles.selectOptionTextSelected,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{isSelected(option.value) && (
|
||||
<Feather name="check" size={18} color="#050505" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTextField = () => {
|
||||
const placeholder = actionData?.placeholder || '请输入内容';
|
||||
const multiline = actionData?.multiline || false;
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
multiline && styles.textInputMultiline,
|
||||
error && styles.textInputError,
|
||||
]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="rgba(255, 255, 255, 0.5)"
|
||||
value={value || ''}
|
||||
onChangeText={onChange}
|
||||
multiline={multiline}
|
||||
textAlignVertical={multiline ? 'top' : 'center'}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageField = () => {
|
||||
const handleImagePick = async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
Alert.alert(
|
||||
'需要权限',
|
||||
'请在设置中允许访问相册以上传图片',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image:', error);
|
||||
Alert.alert('错误', '无法选择图片,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadCard,
|
||||
value && styles.uploadCardFilled,
|
||||
error && styles.uploadCardError,
|
||||
]}
|
||||
onPress={handleImagePick}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{value ? (
|
||||
<Image source={{ uri: value }} style={styles.uploadImage} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.uploadIconWrap}>
|
||||
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
||||
</View>
|
||||
<Text style={styles.uploadLabel}>上传图片</Text>
|
||||
<Text style={styles.uploadDescription}>
|
||||
{actionData?.placeholder || 'PNG, JPG 格式'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderVideoField = () => {
|
||||
const handleVideoPick = async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
Alert.alert(
|
||||
'需要权限',
|
||||
'请在设置中允许访问相册以上传视频',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick video:', error);
|
||||
Alert.alert('错误', '无法选择视频,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadCard,
|
||||
value && styles.uploadCardFilled,
|
||||
error && styles.uploadCardError,
|
||||
]}
|
||||
onPress={handleVideoPick}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{value ? (
|
||||
<View style={styles.videoPreview}>
|
||||
<Feather name="video" size={40} color="#D1FF00" />
|
||||
<Text style={styles.videoPreviewText}>视频已选择</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.uploadIconWrap}>
|
||||
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
||||
</View>
|
||||
<Text style={styles.uploadLabel}>上传视频</Text>
|
||||
<Text style={styles.uploadDescription}>
|
||||
{actionData?.placeholder || 'MP4, MOV 格式'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return renderField();
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fieldContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
fieldDescription: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginBottom: 12,
|
||||
},
|
||||
selectOptions: {
|
||||
gap: 12,
|
||||
},
|
||||
selectOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
},
|
||||
selectOptionSelected: {
|
||||
backgroundColor: '#D1FF00',
|
||||
borderColor: '#D1FF00',
|
||||
},
|
||||
selectOptionError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
selectOptionText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
selectOptionTextSelected: {
|
||||
color: '#050505',
|
||||
},
|
||||
textInput: {
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
fontSize: 15,
|
||||
color: '#FFFFFF',
|
||||
minHeight: 52,
|
||||
},
|
||||
textInputMultiline: {
|
||||
minHeight: 140,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
textInputError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
uploadCard: {
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 188,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
uploadCardFilled: {
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
uploadCardError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
uploadIconWrap: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(209, 255, 0, 0.12)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
uploadLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.6,
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
},
|
||||
uploadDescription: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
uploadImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
videoPreview: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
videoPreviewText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#D1FF00',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user