expo-popcore-old/app/templates/[id]/form.tsx

709 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
},
});