415 lines
11 KiB
TypeScript
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',
|
|
},
|
|
});
|