bw-expo-app/app/templates/[id]/form.tsx

415 lines
11 KiB
TypeScript

import { Feather } from '@expo/vector-icons';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export const unstable_settings = {
headerShown: false,
};
import { BackButton } from '@/components/ui/back-button';
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
import { getTemplateById } from '@/lib/api/templates';
import { recordTokenUsage } from '@/lib/api/balance';
import { runTemplate } from '@/lib/api/template-runs';
import { Template, TemplateGraphNode } from '@/lib/types/template';
const FALLBACK_PREVIEW =
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
const FALLBACK_INSET =
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=640&q=80';
export default function TemplateFormScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [template, setTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
loadInitialData();
}, [id]);
const loadInitialData = async () => {
try {
setLoading(true);
const templateResponse = await getTemplateById(id);
if (templateResponse.success && templateResponse.data) {
setTemplate(templateResponse.data);
// 初始化表单数据
const initialData: Record<string, any> = {};
templateResponse.data.formSchema?.startNodes.forEach((node: TemplateGraphNode) => {
if (node.data.actionData?.allowMultiple) {
initialData[node.id] = [];
} else {
initialData[node.id] = '';
}
});
setFormData(initialData);
}
} catch (error) {
console.error('Failed to load template:', error);
Alert.alert('错误', '加载数据失败,请稍后重试');
} finally {
setLoading(false);
}
};
const validateForm = (): boolean => {
if (!template?.formSchema?.startNodes) return false;
const newErrors: Record<string, string> = {};
template.formSchema.startNodes.forEach((node: TemplateGraphNode) => {
const { id, data } = node;
const { actionData, label } = data;
const value = formData[id];
// 检查必填项
if (actionData?.required) {
if (Array.isArray(value)) {
if (value.length === 0) {
newErrors[id] = `请选择${label}`;
}
} else if (!value || (typeof value === 'string' && !value.trim())) {
newErrors[id] = `请填写${label}`;
}
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const transformFormDataToRunFormat = (formData: Record<string, any>): Record<string, any> => {
if (!template?.formSchema?.startNodes) return {};
const transformed: Record<string, any> = {};
template.formSchema.startNodes.forEach((node: TemplateGraphNode) => {
const { id, type } = node;
const value = formData[id];
// 跳过空值
if (!value || (Array.isArray(value) && value.length === 0)) {
return;
}
// 根据节点类型转换数据格式
switch (type) {
case 'image':
transformed[id] = {
images: [{ url: value }],
};
break;
case 'video':
transformed[id] = {
videos: [{ url: value }],
};
break;
case 'text':
transformed[id] = {
texts: [value],
};
break;
case 'select':
// 根据 allowMultiple 判断是否为多选
if (node.data.actionData?.allowMultiple) {
transformed[id] = {
selections: Array.isArray(value) ? value : [value],
};
} else {
transformed[id] = {
selections: value,
};
}
break;
default:
// 未知类型,保持原样
transformed[id] = value;
}
});
return transformed;
};
const handleSubmit = async () => {
if (!template) return;
if (!validateForm()) {
Alert.alert('提示', '请完善所有必填项');
return;
}
setIsSubmitting(true);
try {
// 1. 扣费(已集成余额检查和错误处理)
const requiredAmount = template.costPrice || 0;
const usageResponse = await recordTokenUsage({
price: requiredAmount,
name: `生成视频 - ${template.title}`,
metadata: {
templateId: template.id,
templateTitle: template.title,
},
});
if (!usageResponse.success) {
// recordTokenUsage 已经显示了错误提示,这里不需要重复
return;
}
// 2. 转换表单数据为正确的 API 格式
const transformedData = transformFormDataToRunFormat(formData);
// 3. 调用 template run
const runResponse = await runTemplate(id, transformedData);
if (!runResponse.success) {
Alert.alert('错误', '生成任务创建失败');
return;
}
const generationId = runResponse.data;
// 4. 跳转到结果页面
Alert.alert('成功', '视频生成任务已创建', [
{
text: '查看结果',
onPress: () => {
router.push(`/result?generationId=${generationId}`);
},
},
]);
} catch (error) {
console.error('Submission failed:', error);
Alert.alert('错误', '提交失败,请稍后重试');
} finally {
setIsSubmitting(false);
}
};
const renderFormFields = () => {
if (!template?.formSchema?.startNodes) return null;
return template.formSchema.startNodes.map((node: TemplateGraphNode) => (
<DynamicFormField
key={node.id}
node={node}
value={formData[node.id]}
onChange={(value) => {
setFormData(prev => ({ ...prev, [node.id]: value }));
if (errors[node.id]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[node.id];
return newErrors;
});
}
}}
error={errors[node.id]}
/>
));
};
const renderStep1 = () => {
const heroImage = template?.previewUrl || template?.coverImageUrl || FALLBACK_PREVIEW;
const insetImage = template?.coverImageUrl || FALLBACK_INSET;
return (
<ScrollView
style={styles.stepScroll}
contentContainerStyle={styles.step1Content}
showsVerticalScrollIndicator={false}
>
<View style={styles.topBar}>
<BackButton onPress={() => router.back()} />
</View>
<Text style={styles.heroTitle}>
{(template?.title ?? 'AI视频生成').toUpperCase()}
</Text>
<Text style={styles.heroSubtitle}>
{template?.description || '使用AI技术快速生成专业视频内容'}
</Text>
<View style={styles.previewStage}>
<Image source={{ uri: heroImage }} style={styles.previewImage} />
<View style={styles.previewInset}>
<Image source={{ uri: insetImage }} style={styles.previewInsetImage} />
</View>
</View>
{renderFormFields()}
<TouchableOpacity
style={[styles.generateButton, isSubmitting && styles.generateButtonDisabled]}
activeOpacity={0.88}
onPress={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#050505" />
) : (
<>
<Feather name="star" size={20} color="#050505" />
<Text style={styles.generateLabel}></Text>
</>
)}
</TouchableOpacity>
</ScrollView>
);
};
if (loading) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.loadingCanvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#D1FF00" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
</SafeAreaView>
</View>
</>
);
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
{renderStep1()}
</SafeAreaView>
</View>
</>
);
}
const styles = StyleSheet.create({
canvas: {
flex: 1,
backgroundColor: '#050505',
},
safeArea: {
flex: 1,
},
loadingCanvas: {
flex: 1,
backgroundColor: '#050505',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: 'rgba(255, 255, 255, 0.7)',
letterSpacing: 0.3,
},
stepScroll: {
flex: 1,
},
step1Content: {
paddingHorizontal: 24,
paddingBottom: 40,
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 0,
paddingTop: 8,
paddingBottom: 8,
gap: 12,
},
stepIndicator: {
flex: 1,
textAlign: 'right',
fontSize: 13,
letterSpacing: 2,
color: 'rgba(255, 255, 255, 0.5)',
},
heroTitle: {
fontSize: 26,
lineHeight: 32,
fontWeight: '700',
color: '#FFFFFF',
letterSpacing: 0.8,
textTransform: 'uppercase',
marginBottom: 16,
},
heroSubtitle: {
fontSize: 14,
lineHeight: 20,
color: 'rgba(255, 255, 255, 0.68)',
marginBottom: 24,
},
previewStage: {
borderRadius: 32,
overflow: 'hidden',
backgroundColor: '#111318',
marginBottom: 28,
},
previewImage: {
width: '100%',
height: 220,
},
previewInset: {
position: 'absolute',
bottom: 18,
left: 18,
width: 74,
height: 74,
borderRadius: 24,
overflow: 'hidden',
},
previewInsetImage: {
width: '100%',
height: '100%',
},
generateButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
height: 58,
borderRadius: 30,
backgroundColor: '#D1FF00',
gap: 10,
},
generateButtonDisabled: {
opacity: 0.6,
},
generateLabel: {
fontSize: 17,
fontWeight: '700',
letterSpacing: 0.4,
color: '#050505',
},
});