709 lines
20 KiB
TypeScript
709 lines
20 KiB
TypeScript
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Image,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
View,
|
||
} from 'react-native';
|
||
|
||
export const unstable_settings = {
|
||
headerShown: false,
|
||
};
|
||
|
||
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';
|
||
import { Header } from '@/components/sker/header';
|
||
import { Page } from '@/components/sker/page';
|
||
import { GenerateBtn } from '@/components/sker/generate-btn';
|
||
import VideoPlayer from '@/components/sker/video-player/video-player';
|
||
import { getVideoThumbnail } from '@/lib/utils/media';
|
||
|
||
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 });
|
||
|
||
const pollCancelRef = useRef<(() => void) | null>(null);
|
||
|
||
useEffect(() => {
|
||
loadInitialData();
|
||
|
||
return () => {
|
||
if (pollCancelRef.current) {
|
||
pollCancelRef.current();
|
||
pollCancelRef.current = null;
|
||
}
|
||
};
|
||
}, [id]);
|
||
|
||
const loadInitialData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const templateResponse = await getTemplateById(id);
|
||
|
||
if (templateResponse?.success && templateResponse?.data) {
|
||
setTemplate(templateResponse.data as any);
|
||
// 初始化表单数据
|
||
const initialData: Record<string, any> = {};
|
||
(templateResponse.data.formSchema as any)?.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: '正在生成内容,请稍候...',
|
||
});
|
||
|
||
// 清理之前的轮询
|
||
if (pollCancelRef.current) {
|
||
pollCancelRef.current();
|
||
}
|
||
|
||
// 使用轮询函数
|
||
const cancelPoll = pollTemplateGeneration(
|
||
generationId,
|
||
// 成功回调
|
||
(result: TemplateGeneration) => {
|
||
setPollingStatus({ isPolling: false });
|
||
pollCancelRef.current = null;
|
||
|
||
// 跳转到结果页面
|
||
router.push(`/result?generationId=${generationId}`);
|
||
},
|
||
// 错误回调
|
||
(error: Error) => {
|
||
setPollingStatus({ isPolling: false });
|
||
pollCancelRef.current = null;
|
||
Alert.alert('生成失败', error.message || '任务执行失败,请重试');
|
||
},
|
||
// 最大尝试次数(100次 * 3秒 = 5分钟)
|
||
100,
|
||
// 轮询间隔(3秒)
|
||
3000
|
||
);
|
||
pollCancelRef.current = cancelPoll;
|
||
} catch (error) {
|
||
console.error('Submission failed:', error);
|
||
Alert.alert('错误', '提交失败,请稍后重试');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const renderFormFields = () => {
|
||
if (!template?.formSchema?.startNodes) return null;
|
||
|
||
const nodes = template.formSchema.startNodes;
|
||
const imageNodes = nodes.filter((node: TemplateGraphNode) => node.type === 'image');
|
||
|
||
// 当恰好有2个图片字段时,使用横向布局
|
||
if (imageNodes.length === 2) {
|
||
const result: React.ReactNode[] = [];
|
||
let imageIndex = 0;
|
||
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
const node = nodes[i];
|
||
|
||
if (node.type === 'image') {
|
||
// 第一个图片字段:开始行容器
|
||
if (imageIndex === 0) {
|
||
const firstImage = node;
|
||
const secondImage = imageNodes[1];
|
||
|
||
result.push(
|
||
<View key={`image-row`} style={styles.imageRow}>
|
||
<View style={styles.imageColumn}>
|
||
<DynamicFormField
|
||
node={firstImage}
|
||
value={formData[firstImage.id]}
|
||
onChange={(value) => {
|
||
setFormData(prev => ({ ...prev, [firstImage.id]: value }));
|
||
if (errors[firstImage.id]) {
|
||
setErrors(prev => {
|
||
const newErrors = { ...prev };
|
||
delete newErrors[firstImage.id];
|
||
return newErrors;
|
||
});
|
||
}
|
||
}}
|
||
error={errors[firstImage.id]}
|
||
/>
|
||
</View>
|
||
<View style={styles.imageColumn}>
|
||
<DynamicFormField
|
||
node={secondImage}
|
||
value={formData[secondImage.id]}
|
||
onChange={(value) => {
|
||
setFormData(prev => ({ ...prev, [secondImage.id]: value }));
|
||
if (errors[secondImage.id]) {
|
||
setErrors(prev => {
|
||
const newErrors = { ...prev };
|
||
delete newErrors[secondImage.id];
|
||
return newErrors;
|
||
});
|
||
}
|
||
}}
|
||
error={errors[secondImage.id]}
|
||
/>
|
||
</View>
|
||
</View>
|
||
);
|
||
imageIndex++;
|
||
}
|
||
// 第二个图片字段已在上面处理,跳过
|
||
imageIndex++;
|
||
} else {
|
||
// 非图片字段正常渲染
|
||
result.push(
|
||
<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]}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// 默认垂直布局
|
||
return nodes.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 renderGenerating = () => {
|
||
if (!template) return null;
|
||
|
||
return (
|
||
<ScrollView
|
||
style={styles.stepScroll}
|
||
contentContainerStyle={styles.step1Content}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<View style={styles.generatingContainer}>
|
||
<View style={styles.generatingImageWrapper}>
|
||
<Image source={{ uri: getVideoThumbnail(template.previewUrl, { width: 144, height: 240 }) }} style={styles.uploadedImage} resizeMode="cover" />
|
||
</View>
|
||
|
||
<Image source={require('@/assets/images/star.png')} style={{ width: 32, height: 32 }} />
|
||
<Text style={styles.generatingText}>正在加载生成中...</Text>
|
||
|
||
<View style={styles.progressBarContainer}>
|
||
<View style={styles.progressBarTrack}>
|
||
<View style={styles.progressBarFill} />
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</ScrollView>
|
||
);
|
||
};
|
||
|
||
const renderStep1 = () => {
|
||
if (!template) return null;
|
||
const aspectRatio = template.aspectRatio || '3:4';
|
||
const [w, h] = aspectRatio.split(':');
|
||
const itemWidth = 320;
|
||
const height = itemWidth * parseInt(h) / parseInt(w);
|
||
const posterImage = template.coverImageUrl || template.previewUrl;
|
||
return (
|
||
<ScrollView
|
||
style={styles.stepScroll}
|
||
contentContainerStyle={styles.step1Content}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<Text style={styles.heroSubtitle}>
|
||
{template.descriptionEn}
|
||
</Text>
|
||
<View style={{ position: 'relative', borderRadius: 16, marginBottom: 8, height: height, width: itemWidth, overflow: 'hidden', margin: `auto` }}>
|
||
<VideoPlayer source={{ uri: template.previewUrl }} originalImage={posterImage} />
|
||
</View>
|
||
<View style={{ height: 8 }}></View>
|
||
{pollingStatus.isPolling ? renderGenerating() : renderFormFields()}
|
||
</ScrollView>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Page>
|
||
<Header title={(template?.titleEn ?? 'AI视频生成').toUpperCase()} />
|
||
{renderStep1()}
|
||
<GenerateBtn onGenerate={handleSubmit} loading={isSubmitting || pollingStatus.isPolling} />
|
||
</Page>
|
||
);
|
||
}
|
||
|
||
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: 12,
|
||
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: {
|
||
overflow: 'hidden',
|
||
},
|
||
previewImage: {
|
||
height: '100%',
|
||
width: '100%'
|
||
},
|
||
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',
|
||
},
|
||
imageRow: {
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
marginBottom: 24,
|
||
},
|
||
imageColumn: {
|
||
flex: 1,
|
||
},
|
||
generatingTitle: {
|
||
fontSize: 24,
|
||
fontWeight: '700',
|
||
color: '#FFFFFF',
|
||
letterSpacing: 0.8,
|
||
textAlign: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
generatingSubtitle: {
|
||
fontSize: 14,
|
||
color: 'rgba(255, 255, 255, 0.68)',
|
||
textAlign: 'center',
|
||
marginBottom: 32,
|
||
},
|
||
uploadedImageContainer: {
|
||
marginBottom: 24,
|
||
margin: 'auto'
|
||
},
|
||
generatingContainer: {
|
||
alignItems: 'center',
|
||
paddingVertical: 40,
|
||
backgroundColor: '#232527'
|
||
},
|
||
generatingImageWrapper: {
|
||
position: 'relative',
|
||
marginBottom: 24,
|
||
},
|
||
generatingIconOverlay: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||
borderRadius: 12,
|
||
},
|
||
generatingText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: '#FFFFFF',
|
||
letterSpacing: 0.5,
|
||
marginBottom: 24,
|
||
},
|
||
progressBarContainer: {
|
||
width: '100%',
|
||
paddingHorizontal: 20,
|
||
},
|
||
progressBarTrack: {
|
||
height: 4,
|
||
backgroundColor: 'rgba(136, 245, 250, 0.2)',
|
||
borderRadius: 2,
|
||
overflow: 'hidden',
|
||
},
|
||
progressBarFill: {
|
||
height: '100%',
|
||
width: '60%',
|
||
backgroundColor: '#88F5FA',
|
||
borderRadius: 2,
|
||
},
|
||
uploadedImageLabel: {
|
||
fontSize: 14,
|
||
fontWeight: '600',
|
||
color: 'rgba(255, 255, 255, 0.85)',
|
||
marginBottom: 12,
|
||
letterSpacing: 0.3,
|
||
},
|
||
uploadedImage: {
|
||
width: 144,
|
||
aspectRatio: 1,
|
||
borderRadius: 12,
|
||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||
},
|
||
progressContainer: {
|
||
marginTop: 24,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: 32,
|
||
},
|
||
});
|