Files
bw-expo-app/app/templates/[id]/form.tsx
imeepos 26cd0139bf feat: 完整的生成流程 - 登录检查、文件上传、支付凭证验证
主要功能:
1.  添加 canGenerate() - 检查登录、metered 订阅、余额
2.  添加 uploadBlobFiles() - 自动上传所有 blob URL 到服务器
3.  支付凭证强制验证 - identifier 缺失时报错"支付失败"
4.  新增 lib/api/upload.ts - 文件上传 API
5.  更新 useTemplateRun hook 支持 identifier 参数

完整流程(7步骤):
  1. 表单验证
  2. canGenerate 检查(登录 + 订阅 + 余额)
  3. 上传 blob 文件到服务器
  4. recordTokenUsage 扣费并获取 identifier
  5. 验证所有 URL 都是服务器 URL
  6. 转换数据格式
  7. runTemplate(id, data, identifier) 创建任务

安全保障:
-  无 identifier → 抛出错误"支付凭证缺失"
-  blob URL 未上传 → 报错"存在未上传的文件"
-  余额不足 → 引导用户充值

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 18:51:31 +08:00

519 lines
14 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 { 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 } from '@/lib/api/template-runs';
import { uploadFile } from '@/lib/api/upload';
import { Template, TemplateGraphNode } from '@/lib/types/template';
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);
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: 跳转到结果页面
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',
},
});