554 lines
15 KiB
TypeScript
554 lines
15 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, getUserBalance } from '@/lib/api/balance';
|
||
import { runTemplate, pollTemplateGeneration } from '@/lib/api/template-runs';
|
||
import { uploadFile } from '@/lib/api/upload';
|
||
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
||
import { TemplateGeneration } from '@/lib/types/template-run';
|
||
import { useAuth } from '@/hooks/use-auth';
|
||
|
||
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 { session, isLoading: authLoading } = useAuth();
|
||
|
||
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);
|
||
const [pollingStatus, setPollingStatus] = useState<{
|
||
isPolling: boolean;
|
||
generationId?: string;
|
||
message?: string;
|
||
}>({ isPolling: 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;
|
||
};
|
||
|
||
/**
|
||
* 检查用户是否可以生成(登录 + metered 订阅 + 余额充足)
|
||
*/
|
||
const canGenerate = async (): Promise<{ canProceed: boolean; message?: string }> => {
|
||
// 1. 检查是否登录
|
||
if (!session?.user) {
|
||
return {
|
||
canProceed: false,
|
||
message: '请先登录',
|
||
};
|
||
}
|
||
|
||
// 2. 检查是否有 metered 订阅
|
||
const balanceResponse = await getUserBalance();
|
||
if (!balanceResponse.success) {
|
||
return {
|
||
canProceed: false,
|
||
message: balanceResponse.message || '未找到计费订阅,请先订阅',
|
||
};
|
||
}
|
||
|
||
// 3. 检查余额是否充足
|
||
const requiredAmount = template?.costPrice || 0;
|
||
const currentBalance = balanceResponse.data.remainingTokenBalance;
|
||
|
||
if (currentBalance < requiredAmount) {
|
||
return {
|
||
canProceed: false,
|
||
message: `余额不足\n当前余额: ${currentBalance}\n需要费用: ${requiredAmount}`,
|
||
};
|
||
}
|
||
|
||
return { canProceed: true };
|
||
};
|
||
|
||
/**
|
||
* 上传所有 blob URL 文件到服务器
|
||
*/
|
||
const uploadBlobFiles = async (data: Record<string, any>): Promise<Record<string, any>> => {
|
||
if (!template?.formSchema?.startNodes) return data;
|
||
|
||
const uploadedData = { ...data };
|
||
|
||
for (const node of template.formSchema.startNodes) {
|
||
const value = data[node.id];
|
||
|
||
if (!value) continue;
|
||
|
||
// 检查是否是 blob URL
|
||
if (typeof value === 'string' && value.startsWith('blob:')) {
|
||
const fileType = node.type === 'image' ? 'image' : node.type === 'video' ? 'video' : null;
|
||
|
||
if (fileType) {
|
||
const uploadResponse = await uploadFile(value, fileType as 'image' | 'video');
|
||
|
||
if (!uploadResponse.success || !uploadResponse.data) {
|
||
throw new Error(`上传 ${node.data.label} 失败`);
|
||
}
|
||
|
||
uploadedData[node.id] = uploadResponse.data.url;
|
||
}
|
||
}
|
||
}
|
||
|
||
return uploadedData;
|
||
};
|
||
|
||
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 checkResult = await canGenerate();
|
||
if (!checkResult.canProceed) {
|
||
Alert.alert('无法生成', checkResult.message || '请检查账户状态', [
|
||
{ text: '取消', style: 'cancel' },
|
||
{
|
||
text: '去充值',
|
||
onPress: () => router.push('/exchange' as any),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// 步骤 2: 上传所有 blob 文件到服务器
|
||
let uploadedFormData: Record<string, any>;
|
||
try {
|
||
uploadedFormData = await uploadBlobFiles(formData);
|
||
} catch (uploadError) {
|
||
Alert.alert('上传失败', uploadError instanceof Error ? uploadError.message : '文件上传失败,请重试');
|
||
return;
|
||
}
|
||
|
||
// 步骤 3: 扣费并获取 identifier
|
||
const requiredAmount = template.costPrice || 0;
|
||
const usageResponse = await recordTokenUsage({
|
||
price: requiredAmount,
|
||
name: `生成视频 - ${template.title}`,
|
||
metadata: {
|
||
templateId: template.id,
|
||
templateTitle: template.title,
|
||
},
|
||
});
|
||
|
||
if (!usageResponse.success || !usageResponse.data?.identifier) {
|
||
Alert.alert('扣费失败', usageResponse.message || '请稍后重试');
|
||
return;
|
||
}
|
||
|
||
const paymentIdentifier = usageResponse.data.identifier;
|
||
|
||
// 步骤 4: 验证所有 URL 都是有效的服务器 URL
|
||
const hasInvalidUrl = Object.values(uploadedFormData).some(
|
||
(value) => typeof value === 'string' && value.startsWith('blob:')
|
||
);
|
||
|
||
if (hasInvalidUrl) {
|
||
Alert.alert('错误', '存在未上传的文件,请重试');
|
||
return;
|
||
}
|
||
|
||
// 步骤 5: 转换表单数据为 API 格式
|
||
const transformedData = transformFormDataToRunFormat(uploadedFormData);
|
||
|
||
// 步骤 6: 调用 template run,使用支付凭证 identifier
|
||
const runResponse = await runTemplate(id, transformedData, paymentIdentifier);
|
||
|
||
if (!runResponse.success) {
|
||
Alert.alert('错误', '生成任务创建失败');
|
||
return;
|
||
}
|
||
|
||
const generationId = runResponse.data;
|
||
|
||
// 步骤 7: 开始轮询任务状态
|
||
setPollingStatus({
|
||
isPolling: true,
|
||
generationId,
|
||
message: '正在生成内容,请稍候...',
|
||
});
|
||
|
||
// 使用轮询函数
|
||
pollTemplateGeneration(
|
||
generationId,
|
||
// 成功回调
|
||
(result: TemplateGeneration) => {
|
||
setPollingStatus({ isPolling: false });
|
||
|
||
// 跳转到结果页面
|
||
router.push(`/result?generationId=${generationId}`);
|
||
},
|
||
// 错误回调
|
||
(error: Error) => {
|
||
setPollingStatus({ isPolling: false });
|
||
Alert.alert('生成失败', error.message || '任务执行失败,请重试');
|
||
},
|
||
// 最大尝试次数(100次 * 3秒 = 5分钟)
|
||
100,
|
||
// 轮询间隔(3秒)
|
||
3000
|
||
);
|
||
} 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 || pollingStatus.isPolling) && styles.generateButtonDisabled
|
||
]}
|
||
activeOpacity={0.88}
|
||
onPress={handleSubmit}
|
||
disabled={isSubmitting || pollingStatus.isPolling}
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<ActivityIndicator size="small" color="#050505" />
|
||
<Text style={styles.generateLabel}>提交中...</Text>
|
||
</>
|
||
) : pollingStatus.isPolling ? (
|
||
<>
|
||
<ActivityIndicator size="small" color="#050505" />
|
||
<Text style={styles.generateLabel}>{pollingStatus.message || '生成中...'}</Text>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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',
|
||
},
|
||
});
|