diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 4f78525..fbe3c20 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -1,28 +1,6 @@
{
"permissions": {
- "allow": [
- "WebFetch(domain:docs.expo.dev)",
- "WebFetch(domain:docs.expo.dev)",
- "WebSearch",
- "Bash(git add:*)",
- "Bash(git commit:*)",
- "Bash(npx eas:*)",
- "Bash(npx @expo/eas-cli@latest:*)",
- "Bash(npx expo install:*)",
- "Bash(eas --version:*)",
- "Bash(eas build:configure:*)",
- "Bash(bunx:*)",
- "Bash(./gradlew:*)",
- "Bash(java:*)",
- "Bash(where:*)",
- "Bash(echo:*)",
- "Bash(set:*)",
- "Bash(git --version:*)",
- "Bash(export:*)",
- "Bash(ls:*)",
- "Bash(npx tsc --noEmit)",
- "Bash(curl 'https://api.mixvideo.bowong.cc/api/template-generations?page=1&limit=20' )"
- ],
+ "allow": [],
"deny": [],
"ask": []
}
diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx
index 304654b..8d227ca 100644
--- a/app/(auth)/login.tsx
+++ b/app/(auth)/login.tsx
@@ -30,7 +30,7 @@ export default function LoginScreen() {
useEffect(() => {
if (!authLoading && isAuthenticated) {
console.log("[Login] User authenticated, redirecting to tabs...");
- router.replace("/(tabs)");
+ router.replace("/(tabs)" as any);
}
}, [isAuthenticated, authLoading]);
diff --git a/app/result.tsx b/app/result.tsx
index 168c3c4..6250b97 100644
--- a/app/result.tsx
+++ b/app/result.tsx
@@ -197,13 +197,11 @@ export default function ResultPage() {
return (
diff --git a/app/templates/[id].tsx b/app/templates/[id].tsx
index 9e21303..5905267 100644
--- a/app/templates/[id].tsx
+++ b/app/templates/[id].tsx
@@ -3,7 +3,7 @@ import { getTagByName, type TagTemplate } from '@/lib/api/tags';
import { getTemplateById } from '@/lib/api/templates';
import Feather from '@expo/vector-icons/Feather';
import { Stack, router, useLocalSearchParams } from 'expo-router';
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Image,
@@ -55,6 +55,31 @@ const quickActions = [
{ id: 'download', label: 'Download' },
];
+const PreviewFrame = React.memo<{
+ frame: TemplateFrame;
+ isActive: boolean;
+ onPress: () => void;
+}>(({ frame, isActive, onPress }) => {
+ return (
+
+
+
+ );
+});
+
+PreviewFrame.displayName = 'PreviewFrame';
+
+const CACHE_DURATION = 5 * 60 * 1000;
+const cache = new Map();
+
export default function TemplateDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [looks, setLooks] = useState([]);
@@ -62,19 +87,55 @@ export default function TemplateDetailScreen() {
const [menuVisible, setMenuVisible] = useState(false);
const [loadingLooks, setLoadingLooks] = useState(true);
const [errorMessage, setErrorMessage] = useState(null);
+ const abortControllerRef = useRef(null);
+ const imageCache = useRef(new Set());
+
+ const getCachedData = useCallback((key: string) => {
+ const cached = cache.get(key);
+ if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
+ return cached.data;
+ }
+ return null;
+ }, []);
+
+ const setCachedData = useCallback((key: string, data: TemplateFrame[]) => {
+ cache.set(key, { data, timestamp: Date.now() });
+ }, []);
+
+ const preloadImage = useCallback((uri: string) => {
+ if (imageCache.current.has(uri)) {
+ return;
+ }
+ Image.prefetch(uri).then(() => {
+ imageCache.current.add(uri);
+ }).catch(() => {
+ });
+ }, []);
useEffect(() => {
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = new AbortController();
+ const signal = abortControllerRef.current.signal;
+
let isMounted = true;
- const loadByTagName = async (tagName?: string | null) => {
+ const loadByTagName = async (tagName?: string | null): Promise => {
if (!tagName) {
return [];
}
+ const cacheKey = `tag:${tagName}`;
+ const cached = getCachedData(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
try {
const response = await getTagByName(tagName);
if (response.success && response.data?.templates?.length) {
- return mapTagTemplates(response.data.templates);
+ const templates = mapTagTemplates(response.data.templates);
+ setCachedData(cacheKey, templates);
+ return templates;
}
} catch (tagError) {
console.warn('Failed to load tag templates:', tagError);
@@ -95,70 +156,109 @@ export default function TemplateDetailScreen() {
setLoadingLooks(true);
setErrorMessage(null);
- let frames = await loadByTagName(id);
- let fallbackTemplateId: string | null = id;
+ try {
+ const templateResponse = await getTemplateById(id);
+ if (!signal.aborted && templateResponse.success && templateResponse.data) {
+ const template = templateResponse.data;
+ const primaryTagName = template.tags?.[0]?.nameEn || template.tags?.[0]?.name;
- if (frames.length === 0) {
- try {
- const templateResponse = await getTemplateById(id);
- if (templateResponse.success && templateResponse.data) {
- const template = templateResponse.data;
- fallbackTemplateId = template.id;
- const primaryTagName = template.tags?.[0]?.nameEn || template.tags?.[0]?.name;
- const relatedFrames = await loadByTagName(primaryTagName);
- frames = relatedFrames.length > 0 ? relatedFrames : [createFrame(template, 0)];
+ const [relatedFrames, mainFrame] = await Promise.allSettled([
+ loadByTagName(primaryTagName),
+ Promise.resolve(createFrame(template, 0))
+ ]);
+
+ const frames: TemplateFrame[] = [];
+
+ if (relatedFrames.status === 'fulfilled' && relatedFrames.value.length > 0) {
+ frames.push(...relatedFrames.value);
+ } else {
+ frames.push(mainFrame.status === 'fulfilled' ? mainFrame.value : createFrame(template, 0));
}
- } catch (templateError) {
- console.warn('Failed to fetch template detail:', templateError);
+
+ if (!isMounted || signal.aborted) {
+ return;
+ }
+
+ setLooks(frames);
+ setCachedData(`template:${id}`, frames);
+
+ const preferredId =
+ frames.find(frame => frame.id === id)?.id ??
+ frames[0]?.id ??
+ template.id;
+
+ setSelectedId(preferredId);
+
+ frames.slice(0, 3).forEach(frame => preloadImage(frame.uri));
+ } else {
+ if (!isMounted || signal.aborted) {
+ return;
+ }
+ setErrorMessage('Template not found');
+ setLooks([]);
+ setSelectedId(null);
+ }
+ } catch (templateError) {
+ if (!isMounted || signal.aborted) {
+ return;
+ }
+ console.warn('Failed to fetch template detail:', templateError);
+ setErrorMessage('Failed to load template');
+ setLooks([]);
+ setSelectedId(null);
+ } finally {
+ if (isMounted && !signal.aborted) {
+ setLoadingLooks(false);
}
}
-
- if (!isMounted) {
- return;
- }
-
- if (frames.length === 0) {
- setErrorMessage('No templates available');
- }
-
- setLooks(frames);
-
- const preferredId =
- frames.find(frame => frame.id === id)?.id ??
- frames[0]?.id ??
- fallbackTemplateId ??
- null;
-
- setSelectedId(preferredId);
- setLoadingLooks(false);
};
hydrateLooks();
return () => {
isMounted = false;
+ abortControllerRef.current?.abort();
};
- }, [id]);
+ }, [id, getCachedData, setCachedData, preloadImage]);
- const current = useMemo(
- () => {
- if (looks.length === 0) {
- return null;
- }
+ const current = useMemo(() => {
+ if (looks.length === 0) {
+ return null;
+ }
- if (!selectedId) {
- return looks[0];
- }
+ if (!selectedId) {
+ return looks[0];
+ }
- return looks.find(look => look.id === selectedId) ?? looks[0];
- },
- [looks, selectedId],
- );
+ return looks.find(look => look.id === selectedId) ?? looks[0];
+ }, [looks, selectedId]);
const resolvedTemplateId = current?.id ?? (typeof id === 'string' ? id : '');
const heroImageUri = current?.uri ?? FALLBACK_PREVIEW;
const infoTitle = current?.title ?? 'Loading template';
+ const handleRetry = useCallback(() => {
+ cache.delete(`template:${id}`);
+ cache.delete(`tag:${current?.title}`);
+ setErrorMessage(null);
+ setLoadingLooks(true);
+
+ const event = new Event('retry');
+ window.dispatchEvent(event);
+ }, [id, current?.title]);
+
+ useEffect(() => {
+ const handleRetryEvent = () => {
+ const newController = new AbortController();
+ abortControllerRef.current = newController;
+ };
+
+ window.addEventListener('retry', handleRetryEvent);
+ return () => {
+ window.removeEventListener('retry', handleRetryEvent);
+ };
+ }, []);
+
const handleGenerate = () => {
if (!resolvedTemplateId) {
return;
@@ -198,6 +298,9 @@ export default function TemplateDetailScreen() {
{!loadingLooks && errorMessage && (
{errorMessage}
+
+ Retry
+
)}
@@ -208,23 +311,14 @@ export default function TemplateDetailScreen() {
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.carouselContent}
>
- {looks.map(look => {
- const isActive = look.id === selectedId;
- return (
- setSelectedId(look.id)}
- style={[
- styles.previewFrame,
- { borderColor: isActive ? look.accent : 'rgba(255,255,255,0.08)' },
- isActive && styles.previewFrameActive,
- ]}
- >
-
-
- );
- })}
+ {looks.map(look => (
+ setSelectedId(look.id)}
+ />
+ ))}
) : (
!loadingLooks && (
@@ -334,6 +428,19 @@ const styles = StyleSheet.create({
color: '#FF8A8A',
fontSize: 13,
fontWeight: '600',
+ marginBottom: 8,
+ },
+ retryButton: {
+ backgroundColor: 'rgba(255, 94, 94, 0.2)',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ borderRadius: 12,
+ alignSelf: 'flex-start',
+ },
+ retryLabel: {
+ color: '#FF8A8A',
+ fontSize: 13,
+ fontWeight: '700',
},
headerBar: {
flexDirection: 'row',
diff --git a/app/templates/[id]/form.tsx b/app/templates/[id]/form.tsx
index 3707ca2..38c7a31 100644
--- a/app/templates/[id]/form.tsx
+++ b/app/templates/[id]/form.tsx
@@ -1,7 +1,5 @@
import { Feather } from '@expo/vector-icons';
-import * as ImagePicker from 'expo-image-picker';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
-import { UploadCloud } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
@@ -10,7 +8,6 @@ import {
ScrollView,
StyleSheet,
Text,
- TextInput,
TouchableOpacity,
View,
} from 'react-native';
@@ -20,27 +17,12 @@ export const unstable_settings = {
headerShown: false,
};
-import { DynamicForm } from '@/components/forms/dynamic-form';
import { BackButton } from '@/components/ui/back-button';
+import { DynamicFormField } from '@/components/forms/dynamic-form-field';
import { getTemplateById } from '@/lib/api/templates';
-import { Template } from '@/lib/types/template';
-import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
-import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
-
-interface Step1FormData {
- characterImage: string;
- productImage: string;
- script: string;
-}
-
-interface Step2FormData extends RunTemplateData { }
-
-interface FormData {
- step1: Step1FormData;
- step2: Step2FormData;
-}
-
-type UploadFieldKey = 'characterImage' | 'productImage';
+import { getUserBalance, chargeUser } 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';
@@ -51,20 +33,9 @@ export default function TemplateFormScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
- const [currentStep, setCurrentStep] = useState(1);
const [template, setTemplate] = useState(null);
- const [formSchema, setFormSchema] = useState({ fields: [] });
const [loading, setLoading] = useState(true);
-
- const [formData, setFormData] = useState({
- step1: {
- characterImage: '',
- productImage: '',
- script: '',
- },
- step2: {},
- });
-
+ const [formData, setFormData] = useState>({});
const [errors, setErrors] = useState>({});
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -79,169 +50,149 @@ export default function TemplateFormScreen() {
if (templateResponse.success && templateResponse.data) {
setTemplate(templateResponse.data);
-
- if (templateResponse.data.formSchema) {
- try {
- const rawData =
- typeof templateResponse.data.formSchema === 'string'
- ? JSON.parse(templateResponse.data.formSchema as string)
- : templateResponse.data.formSchema;
-
- const formConfig = transformWorkflowToFormSchema(rawData);
-
- if (validateFormSchema(formConfig)) {
- setFormSchema(formConfig);
- }
- } catch (error) {
- console.error('Failed to parse form schema:', error);
+ // 初始化表单数据
+ const initialData: Record = {};
+ 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('Error', 'Failed to load data. Please retry later.');
+ Alert.alert('错误', '加载数据失败,请稍后重试');
} finally {
setLoading(false);
}
};
- const validateStep1 = (): boolean => {
+ const validateForm = (): boolean => {
+ if (!template?.formSchema?.startNodes) return false;
+
const newErrors: Record = {};
- if (!formData.step1.characterImage) {
- newErrors.characterImage = 'Please upload the character image';
- }
+ template.formSchema.startNodes.forEach((node: TemplateGraphNode) => {
+ const { id, data } = node;
+ const { actionData, label } = data;
+ const value = formData[id];
- if (!formData.step1.productImage) {
- newErrors.productImage = 'Please upload the product image';
- }
-
- if (!formData.step1.script.trim()) {
- newErrors.script = 'Please provide the character script';
- }
+ // 检查必填项
+ 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 handleStep1Next = () => {
- if (validateStep1()) {
- setCurrentStep(2);
- }
- };
-
- const handleStep2Prev = () => {
- setCurrentStep(1);
- };
-
- const handleStep2Submit = async () => {
+ const handleSubmit = async () => {
if (!template) return;
+ if (!validateForm()) {
+ Alert.alert('提示', '请完善所有必填项');
+ return;
+ }
setIsSubmitting(true);
try {
- const combinedData = {
- ...formData.step1,
- ...formData.step2,
- };
+ // 1. 检查余额
+ const balanceResponse = await getUserBalance();
- console.log('Submitting template payload:', combinedData);
+ if (!balanceResponse.success) {
+ Alert.alert('错误', '无法获取账户余额');
+ return;
+ }
- Alert.alert('Success', 'Template configuration saved.', [
+ const balance = balanceResponse.data.balance;
+ const requiredAmount = template.costPrice || 0;
+
+ if (balance < requiredAmount) {
+ Alert.alert(
+ '余额不足',
+ `当前余额: ${balance}\n所需费用: ${requiredAmount}\n请先充值`,
+ [
+ { text: '取消', style: 'cancel' },
+ { text: '去充值', onPress: () => router.push('/recharge') },
+ ]
+ );
+ return;
+ }
+
+ // 2. 扣费
+ const chargeResponse = await chargeUser({
+ amount: requiredAmount,
+ description: `生成视频 - ${template.title}`,
+ metadata: {
+ templateId: template.id,
+ templateTitle: template.title,
+ },
+ });
+
+ if (!chargeResponse.success) {
+ Alert.alert('错误', '扣费失败,请稍后重试');
+ return;
+ }
+
+ const transactionId = chargeResponse.data.id;
+
+ // 3. 调用 template run
+ const runResponse = await runTemplate(id, formData);
+
+ if (!runResponse.success) {
+ Alert.alert('错误', '生成任务创建失败');
+ return;
+ }
+
+ const generationId = runResponse.data;
+
+ // 4. 跳转到结果页面
+ Alert.alert('成功', '视频生成任务已创建', [
{
- text: 'OK',
+ text: '查看结果',
onPress: () => {
- router.push(`/templates/${id}/run`);
+ router.push(`/result?generationId=${generationId}`);
},
},
]);
} catch (error) {
console.error('Submission failed:', error);
- Alert.alert('Error', 'Submission failed. Please retry later.');
+ Alert.alert('错误', '提交失败,请稍后重试');
} finally {
setIsSubmitting(false);
}
};
- const handleImagePick = async (field: UploadFieldKey) => {
- try {
- const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
+ const renderFormFields = () => {
+ if (!template?.formSchema?.startNodes) return null;
- if (!permission.granted) {
- Alert.alert(
- 'Permission Required',
- 'Please allow photo library access in Settings to upload images.',
- );
- return;
- }
-
- const result = await ImagePicker.launchImageLibraryAsync({
- mediaTypes: ImagePicker.MediaTypeOptions.Images,
- allowsEditing: true,
- quality: 0.9,
- });
-
- if (result.canceled || !result.assets || result.assets.length === 0) {
- return;
- }
-
- const uri = result.assets[0].uri;
-
- setFormData(prev => ({
- ...prev,
- step1: {
- ...prev.step1,
- [field]: uri,
- },
- }));
-
- if (errors[field]) {
- setErrors(prev => ({
- ...prev,
- [field]: '',
- }));
- }
- } catch (error) {
- console.error('Failed to pick image:', error);
- Alert.alert('Error', 'Unable to pick image. Please try again later.');
- }
- };
-
- const renderUploadCard = (
- field: UploadFieldKey,
- label: string,
- description: string,
- ) => {
- const value = formData.step1[field];
-
- return (
-
- handleImagePick(field)}
- style={[
- styles.uploadCard,
- value && styles.uploadCardFilled,
- errors[field] && styles.uploadCardError,
- ]}
- >
- {value ? (
-
- ) : (
- <>
-
-
-
- {label}
- {description}
- >
- )}
-
- {errors[field] ? (
- {errors[field]}
- ) : null}
-
- );
+ return template.formSchema.startNodes.map((node: TemplateGraphNode) => (
+ {
+ 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 = () => {
@@ -259,11 +210,10 @@ export default function TemplateFormScreen() {
- {(template?.title ?? 'Insert your product into an ASMR video').toUpperCase()}
+ {(template?.title ?? 'AI视频生成').toUpperCase()}
- {template?.description ||
- 'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.'}
+ {template?.description || '使用AI技术快速生成专业视频内容'}
@@ -273,130 +223,27 @@ export default function TemplateFormScreen() {
-
- {renderUploadCard(
- 'characterImage',
- 'UPLOAD CHARACTER IMAGE',
- 'PNG, JPG or Paste from clipboard',
- )}
- {renderUploadCard(
- 'productImage',
- 'UPLOAD CHARACTER IMAGE',
- 'PNG, JPG or Paste from clipboard',
- )}
-
-
-
- {
- setFormData(prev => ({
- ...prev,
- step1: {
- ...prev.step1,
- script: text,
- },
- }));
- if (errors.script) {
- setErrors(prev => ({
- ...prev,
- script: '',
- }));
- }
- }}
- multiline
- textAlignVertical="top"
- />
- {errors.script ? {errors.script} : null}
-
+ {renderFormFields()}
-
- Generate Video
+ {isSubmitting ? (
+
+ ) : (
+ <>
+
+ 生成视频
+ >
+ )}
);
};
- const renderStep2 = () => (
-
-
-
- STEP 2 OF 2
-
-
- Run Parameters
-
- Fine-tune the details so the ASMR video delivers the mood you expect.
-
-
-
- {formSchema.fields.length > 0 ? (
- {
- setFormData(prev => ({
- ...prev,
- step2: data,
- }));
- }}
- />
- ) : (
-
- 📋
- No extra parameters
-
- This template is ready to go—start generating the ASMR experience instantly.
-
-
- )}
-
-
- );
-
- const renderBottomActions = () => {
- if (currentStep !== 2) {
- return null;
- }
-
- return (
-
-
-
- Back
-
-
- {isSubmitting ? (
-
- ) : (
- <>
-
- Generate Video
- >
- )}
-
-
-
- );
- };
-
if (loading) {
return (
<>
@@ -420,9 +267,8 @@ export default function TemplateFormScreen() {
- {currentStep === 1 ? renderStep1() : renderStep2()}
+ {renderStep1()}
- {renderBottomActions()}
>
);
@@ -512,86 +358,6 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
},
- uploadRow: {
- flexDirection: 'row',
- gap: 18,
- marginBottom: 24,
- },
- uploadItem: {
- flex: 1,
- },
- uploadCard: {
- borderRadius: 28,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.08)',
- backgroundColor: 'rgba(17, 19, 24, 0.95)',
- paddingHorizontal: 20,
- paddingVertical: 28,
- alignItems: 'center',
- justifyContent: 'center',
- minHeight: 188,
- overflow: 'hidden',
- },
- uploadCardFilled: {
- paddingHorizontal: 0,
- paddingVertical: 0,
- },
- uploadCardError: {
- borderColor: '#FF6B6B',
- },
- uploadIconWrap: {
- width: 60,
- height: 60,
- borderRadius: 22,
- backgroundColor: 'rgba(209, 255, 0, 0.12)',
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: 16,
- },
- uploadLabel: {
- fontSize: 14,
- fontWeight: '700',
- letterSpacing: 0.6,
- color: '#FFFFFF',
- textAlign: 'center',
- textTransform: 'uppercase',
- marginBottom: 6,
- },
- uploadDescription: {
- fontSize: 12,
- lineHeight: 16,
- color: 'rgba(255, 255, 255, 0.55)',
- textAlign: 'center',
- },
- uploadImage: {
- width: '100%',
- height: '100%',
- resizeMode: 'cover',
- },
- promptField: {
- marginBottom: 18,
- },
- promptInput: {
- borderRadius: 28,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.08)',
- backgroundColor: 'rgba(17, 19, 24, 0.95)',
- minHeight: 140,
- paddingHorizontal: 22,
- paddingVertical: 22,
- fontSize: 15,
- lineHeight: 22,
- color: '#FFFFFF',
- },
- promptInputError: {
- borderColor: '#FF6B6B',
- },
- inlineError: {
- marginTop: 8,
- fontSize: 12,
- color: '#FF6B6B',
- letterSpacing: 0.2,
- },
generateButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -601,100 +367,13 @@ const styles = StyleSheet.create({
backgroundColor: '#D1FF00',
gap: 10,
},
+ generateButtonDisabled: {
+ opacity: 0.6,
+ },
generateLabel: {
fontSize: 17,
fontWeight: '700',
letterSpacing: 0.4,
color: '#050505',
},
- step2Wrapper: {
- flex: 1,
- paddingHorizontal: 24,
- paddingBottom: 24,
- },
- step2Title: {
- fontSize: 22,
- fontWeight: '700',
- color: '#FFFFFF',
- letterSpacing: 0.6,
- marginBottom: 10,
- },
- step2Subtitle: {
- fontSize: 13,
- lineHeight: 18,
- color: 'rgba(255, 255, 255, 0.6)',
- marginBottom: 20,
- },
- step2FormShell: {
- flex: 1,
- borderRadius: 32,
- backgroundColor: 'rgba(17, 19, 24, 0.78)',
- overflow: 'hidden',
- },
- emptyState: {
- flex: 1,
- paddingHorizontal: 32,
- alignItems: 'center',
- justifyContent: 'center',
- },
- emptyIcon: {
- fontSize: 48,
- marginBottom: 16,
- color: '#FFFFFF',
- },
- emptyText: {
- fontSize: 18,
- fontWeight: '600',
- color: '#FFFFFF',
- marginBottom: 8,
- },
- emptyDescription: {
- fontSize: 14,
- lineHeight: 20,
- color: 'rgba(255, 255, 255, 0.6)',
- textAlign: 'center',
- },
- bottomSafeArea: {
- backgroundColor: '#050505',
- },
- bottomActions: {
- flexDirection: 'row',
- gap: 16,
- paddingHorizontal: 24,
- paddingTop: 12,
- paddingBottom: 24,
- borderTopWidth: 1,
- borderTopColor: 'rgba(255, 255, 255, 0.08)',
- },
- secondaryButton: {
- flex: 1,
- height: 56,
- borderRadius: 28,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.2)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- secondaryLabel: {
- fontSize: 15,
- fontWeight: '600',
- color: 'rgba(255, 255, 255, 0.78)',
- letterSpacing: 0.2,
- },
- submitButton: {
- flex: 1.6,
- height: 56,
- borderRadius: 28,
- backgroundColor: '#D1FF00',
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 10,
- },
- submitLabel: {
- fontSize: 16,
- fontWeight: '700',
- color: '#050505',
- letterSpacing: 0.3,
- },
});
diff --git a/components/forms/dynamic-form-field.tsx b/components/forms/dynamic-form-field.tsx
new file mode 100644
index 0000000..0b05552
--- /dev/null
+++ b/components/forms/dynamic-form-field.tsx
@@ -0,0 +1,391 @@
+import { TemplateGraphNode } from '@/lib/types/template';
+import { Feather } from '@expo/vector-icons';
+import * as ImagePicker from 'expo-image-picker';
+import { UploadCloud } from 'lucide-react';
+import React from 'react';
+import {
+ Alert,
+ Image,
+ StyleSheet,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+interface DynamicFormFieldProps {
+ node: TemplateGraphNode;
+ value: any;
+ onChange: (value: any) => void;
+ error?: string;
+}
+
+export function DynamicFormField({ node, value, onChange, error }: DynamicFormFieldProps) {
+ const { type, data } = node;
+ const { label, description, actionData } = data;
+
+ const renderField = () => {
+ switch (type) {
+ case 'select':
+ return renderSelectField();
+ case 'text':
+ return renderTextField();
+ case 'image':
+ return renderImageField();
+ case 'video':
+ return renderVideoField();
+ default:
+ return null;
+ }
+ };
+
+ const renderSelectField = () => {
+ const options = actionData?.options || [];
+ const allowMultiple = actionData?.allowMultiple || false;
+ const placeholder = actionData?.placeholder || '请选择';
+
+ const handleSelect = (optionValue: string) => {
+ if (allowMultiple) {
+ const currentValues = Array.isArray(value) ? value : [];
+ const newValues = currentValues.includes(optionValue)
+ ? currentValues.filter((v: string) => v !== optionValue)
+ : [...currentValues, optionValue];
+ onChange(newValues);
+ } else {
+ onChange(optionValue);
+ }
+ };
+
+ const isSelected = (optionValue: string) => {
+ if (allowMultiple) {
+ return Array.isArray(value) && value.includes(optionValue);
+ }
+ return value === optionValue;
+ };
+
+ return (
+
+ {label}
+ {description && {description}}
+
+
+ {options.map((option: any, index: number) => (
+ handleSelect(option.value)}
+ >
+
+ {option.label}
+
+ {isSelected(option.value) && (
+
+ )}
+
+ ))}
+
+
+ {error && {error}}
+
+ );
+ };
+
+ const renderTextField = () => {
+ const placeholder = actionData?.placeholder || '请输入内容';
+ const multiline = actionData?.multiline || false;
+
+ return (
+
+ {label}
+ {description && {description}}
+
+
+
+ {error && {error}}
+
+ );
+ };
+
+ const renderImageField = () => {
+ const handleImagePick = async () => {
+ try {
+ const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
+
+ if (!permission.granted) {
+ Alert.alert(
+ '需要权限',
+ '请在设置中允许访问相册以上传图片',
+ );
+ return;
+ }
+
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
+ allowsEditing: true,
+ quality: 0.9,
+ });
+
+ if (!result.canceled && result.assets && result.assets.length > 0) {
+ onChange(result.assets[0].uri);
+ }
+ } catch (error) {
+ console.error('Failed to pick image:', error);
+ Alert.alert('错误', '无法选择图片,请稍后重试');
+ }
+ };
+
+ return (
+
+ {label}
+ {description && {description}}
+
+
+ {value ? (
+
+ ) : (
+ <>
+
+
+
+ 上传图片
+
+ {actionData?.placeholder || 'PNG, JPG 格式'}
+
+ >
+ )}
+
+
+ {error && {error}}
+
+ );
+ };
+
+ const renderVideoField = () => {
+ const handleVideoPick = async () => {
+ try {
+ const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
+
+ if (!permission.granted) {
+ Alert.alert(
+ '需要权限',
+ '请在设置中允许访问相册以上传视频',
+ );
+ return;
+ }
+
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ImagePicker.MediaTypeOptions.Videos,
+ allowsEditing: true,
+ quality: 0.9,
+ });
+
+ if (!result.canceled && result.assets && result.assets.length > 0) {
+ onChange(result.assets[0].uri);
+ }
+ } catch (error) {
+ console.error('Failed to pick video:', error);
+ Alert.alert('错误', '无法选择视频,请稍后重试');
+ }
+ };
+
+ return (
+
+ {label}
+ {description && {description}}
+
+
+ {value ? (
+
+
+ 视频已选择
+
+ ) : (
+ <>
+
+
+
+ 上传视频
+
+ {actionData?.placeholder || 'MP4, MOV 格式'}
+
+ >
+ )}
+
+
+ {error && {error}}
+
+ );
+ };
+
+ return renderField();
+}
+
+const styles = StyleSheet.create({
+ fieldContainer: {
+ marginBottom: 24,
+ },
+ fieldLabel: {
+ fontSize: 16,
+ fontWeight: '700',
+ letterSpacing: 0.4,
+ color: '#FFFFFF',
+ marginBottom: 8,
+ textTransform: 'uppercase',
+ },
+ fieldDescription: {
+ fontSize: 13,
+ lineHeight: 18,
+ color: 'rgba(255, 255, 255, 0.6)',
+ marginBottom: 12,
+ },
+ selectOptions: {
+ gap: 12,
+ },
+ selectOption: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ borderRadius: 24,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.08)',
+ backgroundColor: 'rgba(17, 19, 24, 0.95)',
+ },
+ selectOptionSelected: {
+ backgroundColor: '#D1FF00',
+ borderColor: '#D1FF00',
+ },
+ selectOptionError: {
+ borderColor: '#FF6B6B',
+ },
+ selectOptionText: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ },
+ selectOptionTextSelected: {
+ color: '#050505',
+ },
+ textInput: {
+ borderRadius: 24,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.08)',
+ backgroundColor: 'rgba(17, 19, 24, 0.95)',
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ fontSize: 15,
+ color: '#FFFFFF',
+ minHeight: 52,
+ },
+ textInputMultiline: {
+ minHeight: 140,
+ paddingTop: 16,
+ paddingBottom: 16,
+ },
+ textInputError: {
+ borderColor: '#FF6B6B',
+ },
+ uploadCard: {
+ borderRadius: 28,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.08)',
+ backgroundColor: 'rgba(17, 19, 24, 0.95)',
+ paddingHorizontal: 20,
+ paddingVertical: 28,
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 188,
+ overflow: 'hidden',
+ },
+ uploadCardFilled: {
+ paddingHorizontal: 0,
+ paddingVertical: 0,
+ },
+ uploadCardError: {
+ borderColor: '#FF6B6B',
+ },
+ uploadIconWrap: {
+ width: 60,
+ height: 60,
+ borderRadius: 22,
+ backgroundColor: 'rgba(209, 255, 0, 0.12)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 16,
+ },
+ uploadLabel: {
+ fontSize: 14,
+ fontWeight: '700',
+ letterSpacing: 0.6,
+ color: '#FFFFFF',
+ textAlign: 'center',
+ textTransform: 'uppercase',
+ marginBottom: 6,
+ },
+ uploadDescription: {
+ fontSize: 12,
+ lineHeight: 16,
+ color: 'rgba(255, 255, 255, 0.55)',
+ textAlign: 'center',
+ },
+ uploadImage: {
+ width: '100%',
+ height: '100%',
+ resizeMode: 'cover',
+ },
+ videoPreview: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 40,
+ },
+ videoPreviewText: {
+ marginTop: 12,
+ fontSize: 14,
+ color: '#D1FF00',
+ fontWeight: '600',
+ },
+ errorText: {
+ marginTop: 8,
+ fontSize: 12,
+ color: '#FF6B6B',
+ letterSpacing: 0.2,
+ },
+});
diff --git a/components/forms/dynamic-form.tsx b/components/forms/dynamic-form.tsx
index 396c69d..c28264a 100644
--- a/components/forms/dynamic-form.tsx
+++ b/components/forms/dynamic-form.tsx
@@ -58,7 +58,7 @@ export function DynamicForm({
} finally {
setIsValidating(false);
}
- }, [schema, initialData, onDataChange]);
+ }, [schema, initialData]);
// 验证单个字段
const validateField = (field: FormFieldSchema, value: any): string | null => {
diff --git a/components/video/fullscreen-video-modal.tsx b/components/video/fullscreen-video-modal.tsx
index 70cf8b0..c42cfce 100644
--- a/components/video/fullscreen-video-modal.tsx
+++ b/components/video/fullscreen-video-modal.tsx
@@ -1,6 +1,6 @@
import { ThemedText } from '@/components/themed-text';
import { VideoView, useVideoPlayer } from 'expo-video';
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
@@ -43,10 +43,15 @@ export function FullscreenVideoModal({
hasNext = false,
hasPrevious = false,
}: FullscreenVideoModalProps) {
- const player = useVideoPlayer(
- {
- uri: videoUrl,
- },
+ // 使用useRef存储player实例,避免无限循环
+ const playerRef = useRef(null);
+
+ // 稳定videoUrl,使用useMemo
+ const stableVideoUrl = useMemo(() => ({ uri: videoUrl }), [videoUrl]);
+
+ // 始终调用useVideoPlayer,但只在videoUrl变化时更新player
+ const newPlayer = useVideoPlayer(
+ stableVideoUrl,
(player) => {
player.loop = isLooping;
player.muted = isMuted;
@@ -56,6 +61,14 @@ export function FullscreenVideoModal({
}
);
+ // 只有当newPlayer变化时才更新player
+ useEffect(() => {
+ playerRef.current = newPlayer;
+ }, [newPlayer]);
+
+ // 获取当前的player实例
+ const player = playerRef.current || newPlayer;
+
const [isLoading, setIsLoading] = useState(true);
// 重置状态当模态框打开/关闭时
@@ -63,8 +76,9 @@ export function FullscreenVideoModal({
if (visible) {
setIsLoading(true);
} else {
- if (player) {
- player.pause();
+ const currentPlayer = playerRef.current;
+ if (currentPlayer) {
+ currentPlayer.pause();
}
}
}, [visible, autoPlay]);
@@ -73,7 +87,8 @@ export function FullscreenVideoModal({
const handleVideoReady = () => {
setIsLoading(false);
if (autoPlay) {
- player?.play();
+ const currentPlayer = playerRef.current;
+ currentPlayer?.play();
}
};
@@ -160,9 +175,8 @@ export function FullscreenVideoModal({
)}
diff --git a/components/video/video-player.tsx b/components/video/video-player.tsx
index e31a4b6..cc48a96 100644
--- a/components/video/video-player.tsx
+++ b/components/video/video-player.tsx
@@ -1,22 +1,21 @@
-import React, { useState, useRef, useEffect } from 'react';
-import {
- View,
- StyleSheet,
- TouchableOpacity,
- Dimensions,
- ActivityIndicator,
- Platform,
-} from 'react-native';
-import { VideoView, useVideoPlayer } from 'expo-video';
-import { Image } from 'expo-image';
import { ThemedView } from '@/components/themed-view';
-import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
import {
calculateOptimalVideoSize,
getVideoFileType,
isValidVideoUrl
} from '@/utils/video-utils';
+import { Image } from 'expo-image';
+import { VideoView, useVideoPlayer } from 'expo-video';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ ActivityIndicator,
+ Dimensions,
+ Platform,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from 'react-native';
// ResizeMode 兼容映射
const ResizeMode = {
@@ -78,32 +77,48 @@ export function VideoPlayer({
onPress,
fullscreenMode = false,
}: VideoPlayerProps) {
- // 使用新的 expo-video API - 使用空的初始化回调,避免依赖外部变量
- const player = useVideoPlayer(
- {
- uri: source.uri,
- },
+ // 使用useMemo稳定source对象,避免不必要的重新渲染
+ const stableSource = useMemo(() => ({ uri: source.uri }), [source.uri]);
+
+ // 使用useRef存储player实例,避免无限循环
+ const playerRef = useRef(null);
+
+ // 始终调用useVideoPlayer,但只在source变化时更新player
+ const newPlayer = useVideoPlayer(
+ stableSource,
(player) => {
- // 空初始化回调,避免每次渲染时重新创建
+ // 空初始化回调
}
);
+ // 只有当newPlayer变化时才更新player
+ useEffect(() => {
+ playerRef.current = newPlayer;
+ }, [newPlayer]);
+
// 单独处理播放属性变化
useEffect(() => {
+ const player = playerRef.current;
if (player) {
player.loop = isLooping;
player.muted = isMuted;
}
- }, [player, isLooping, isMuted]);
+ }, [isLooping, isMuted]);
// 处理自动播放
useEffect(() => {
- if (player && autoPlay && shouldPlay) {
- player.play();
- } else if (player && !shouldPlay) {
- player.pause();
+ const player = playerRef.current;
+ if (player) {
+ if (autoPlay && shouldPlay) {
+ player.play();
+ } else if (!shouldPlay) {
+ player.pause();
+ }
}
- }, [player, autoPlay, shouldPlay]);
+ }, [autoPlay, shouldPlay]);
+
+ // 获取当前的player实例
+ const player = playerRef.current || newPlayer;
const [videoMetadata, setVideoMetadata] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -282,7 +297,6 @@ export function VideoPlayer({
style={styles.video}
contentFit={resizeMode as any}
nativeControls={useNativeControls}
- onError={handleVideoError}
/>
)}
>
diff --git a/lib/api/balance.ts b/lib/api/balance.ts
new file mode 100644
index 0000000..f792ac9
--- /dev/null
+++ b/lib/api/balance.ts
@@ -0,0 +1,50 @@
+import { apiClient } from './client';
+
+export interface UserBalance {
+ userId: string;
+ balance: number;
+ currency: string;
+}
+
+export interface BalanceResponse {
+ success: boolean;
+ data: UserBalance;
+}
+
+export interface ChargeRequest {
+ amount: number;
+ description?: string;
+ metadata?: Record;
+}
+
+export interface ChargeTransaction {
+ id: string;
+ userId: string;
+ amount: number;
+ type: 'charge' | 'refund';
+ description?: string;
+ metadata?: Record;
+ createdAt: string;
+}
+
+export interface ChargeResponse {
+ success: boolean;
+ data: ChargeTransaction;
+}
+
+/**
+ * 获取用户余额
+ */
+export async function getUserBalance(): Promise {
+ return apiClient('/api/users/balance');
+}
+
+/**
+ * 扣费
+ */
+export async function chargeUser(data: ChargeRequest): Promise {
+ return apiClient('/api/users/charge', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+}
diff --git a/lib/types/template.ts b/lib/types/template.ts
index a8cd162..72e37de 100644
--- a/lib/types/template.ts
+++ b/lib/types/template.ts
@@ -38,6 +38,13 @@ export interface TemplateNodeActionData {
aspectRatio?: string;
selectedModel?: string;
advancedParams?: Record;
+ // Select field
+ options?: Array<{ label: string; value: string }>;
+ required?: boolean;
+ placeholder?: string;
+ allowMultiple?: boolean;
+ // Text field
+ multiline?: boolean;
}
export interface TemplateNodeMetrics {
@@ -129,6 +136,8 @@ export interface Template {
uploadSapecifications?: string | null;
uploadSapecificationsEn?: string | null;
sortOrder?: number;
+ costPrice?: number | null;
+ price?: number | null;
category: Category | null;
tags: Tag[];
templateTags?: TemplateTagLink[];