✨ feat: 性能优化与余额扣费系统集成
主要更新: - 🚀 模板详情页性能优化:添加缓存机制、图片预加载、请求中断控制 - 💰 集成余额检查与扣费功能到表单提交流程 - 🔄 添加错误重试机制,提升用户体验 - 🎨 重构动态表单字段为独立组件 (DynamicFormField) - 🔧 修复类型错误,优化样式结构 技术细节: - 实现5分钟缓存策略减少API调用 - 使用 AbortController 管理请求生命周期 - React.memo 优化列表项渲染性能 - 图片预缓存提升加载体验 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,6 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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' )"
|
|
||||||
],
|
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function LoginScreen() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && isAuthenticated) {
|
if (!authLoading && isAuthenticated) {
|
||||||
console.log("[Login] User authenticated, redirecting to tabs...");
|
console.log("[Login] User authenticated, redirecting to tabs...");
|
||||||
router.replace("/(tabs)");
|
router.replace("/(tabs)" as any);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, authLoading]);
|
}, [isAuthenticated, authLoading]);
|
||||||
|
|
||||||
|
|||||||
@@ -197,13 +197,11 @@ export default function ResultPage() {
|
|||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
src={url}
|
src={url}
|
||||||
style={[
|
style={{
|
||||||
{
|
width: '100%',
|
||||||
width: '100%',
|
height: '100%',
|
||||||
height: '100%',
|
objectFit: 'cover' as any
|
||||||
objectFit: 'cover' as any
|
}}
|
||||||
}
|
|
||||||
]}
|
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getTagByName, type TagTemplate } from '@/lib/api/tags';
|
|||||||
import { getTemplateById } from '@/lib/api/templates';
|
import { getTemplateById } from '@/lib/api/templates';
|
||||||
import Feather from '@expo/vector-icons/Feather';
|
import Feather from '@expo/vector-icons/Feather';
|
||||||
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
@@ -55,6 +55,31 @@ const quickActions = [
|
|||||||
{ id: 'download', label: 'Download' },
|
{ id: 'download', label: 'Download' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PreviewFrame = React.memo<{
|
||||||
|
frame: TemplateFrame;
|
||||||
|
isActive: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}>(({ frame, isActive, onPress }) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.previewFrame,
|
||||||
|
{ borderColor: isActive ? frame.accent : 'rgba(255,255,255,0.08)' },
|
||||||
|
isActive && styles.previewFrameActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Image source={{ uri: frame.uri }} style={styles.previewImage} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PreviewFrame.displayName = 'PreviewFrame';
|
||||||
|
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000;
|
||||||
|
const cache = new Map<string, { data: TemplateFrame[]; timestamp: number }>();
|
||||||
|
|
||||||
export default function TemplateDetailScreen() {
|
export default function TemplateDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const [looks, setLooks] = useState<TemplateFrame[]>([]);
|
const [looks, setLooks] = useState<TemplateFrame[]>([]);
|
||||||
@@ -62,19 +87,55 @@ export default function TemplateDetailScreen() {
|
|||||||
const [menuVisible, setMenuVisible] = useState(false);
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
const [loadingLooks, setLoadingLooks] = useState(true);
|
const [loadingLooks, setLoadingLooks] = useState(true);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const imageCache = useRef(new Set<string>());
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
const signal = abortControllerRef.current.signal;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const loadByTagName = async (tagName?: string | null) => {
|
const loadByTagName = async (tagName?: string | null): Promise<TemplateFrame[]> => {
|
||||||
if (!tagName) {
|
if (!tagName) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `tag:${tagName}`;
|
||||||
|
const cached = getCachedData(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getTagByName(tagName);
|
const response = await getTagByName(tagName);
|
||||||
if (response.success && response.data?.templates?.length) {
|
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) {
|
} catch (tagError) {
|
||||||
console.warn('Failed to load tag templates:', tagError);
|
console.warn('Failed to load tag templates:', tagError);
|
||||||
@@ -95,70 +156,109 @@ export default function TemplateDetailScreen() {
|
|||||||
setLoadingLooks(true);
|
setLoadingLooks(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
let frames = await loadByTagName(id);
|
try {
|
||||||
let fallbackTemplateId: string | null = id;
|
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) {
|
const [relatedFrames, mainFrame] = await Promise.allSettled([
|
||||||
try {
|
loadByTagName(primaryTagName),
|
||||||
const templateResponse = await getTemplateById(id);
|
Promise.resolve(createFrame(template, 0))
|
||||||
if (templateResponse.success && templateResponse.data) {
|
]);
|
||||||
const template = templateResponse.data;
|
|
||||||
fallbackTemplateId = template.id;
|
const frames: TemplateFrame[] = [];
|
||||||
const primaryTagName = template.tags?.[0]?.nameEn || template.tags?.[0]?.name;
|
|
||||||
const relatedFrames = await loadByTagName(primaryTagName);
|
if (relatedFrames.status === 'fulfilled' && relatedFrames.value.length > 0) {
|
||||||
frames = relatedFrames.length > 0 ? relatedFrames : [createFrame(template, 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();
|
hydrateLooks();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id, getCachedData, setCachedData, preloadImage]);
|
||||||
|
|
||||||
const current = useMemo(
|
const current = useMemo(() => {
|
||||||
() => {
|
if (looks.length === 0) {
|
||||||
if (looks.length === 0) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
return looks[0];
|
return looks[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return looks.find(look => look.id === selectedId) ?? looks[0];
|
return looks.find(look => look.id === selectedId) ?? looks[0];
|
||||||
},
|
}, [looks, selectedId]);
|
||||||
[looks, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolvedTemplateId = current?.id ?? (typeof id === 'string' ? id : '');
|
const resolvedTemplateId = current?.id ?? (typeof id === 'string' ? id : '');
|
||||||
const heroImageUri = current?.uri ?? FALLBACK_PREVIEW;
|
const heroImageUri = current?.uri ?? FALLBACK_PREVIEW;
|
||||||
const infoTitle = current?.title ?? 'Loading template';
|
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 = () => {
|
const handleGenerate = () => {
|
||||||
if (!resolvedTemplateId) {
|
if (!resolvedTemplateId) {
|
||||||
return;
|
return;
|
||||||
@@ -198,6 +298,9 @@ export default function TemplateDetailScreen() {
|
|||||||
{!loadingLooks && errorMessage && (
|
{!loadingLooks && errorMessage && (
|
||||||
<View style={styles.errorBanner}>
|
<View style={styles.errorBanner}>
|
||||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={handleRetry}>
|
||||||
|
<Text style={styles.retryLabel}>Retry</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -208,23 +311,14 @@ export default function TemplateDetailScreen() {
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.carouselContent}
|
contentContainerStyle={styles.carouselContent}
|
||||||
>
|
>
|
||||||
{looks.map(look => {
|
{looks.map(look => (
|
||||||
const isActive = look.id === selectedId;
|
<PreviewFrame
|
||||||
return (
|
key={look.id}
|
||||||
<TouchableOpacity
|
frame={look}
|
||||||
key={look.id}
|
isActive={look.id === selectedId}
|
||||||
activeOpacity={0.85}
|
onPress={() => setSelectedId(look.id)}
|
||||||
onPress={() => setSelectedId(look.id)}
|
/>
|
||||||
style={[
|
))}
|
||||||
styles.previewFrame,
|
|
||||||
{ borderColor: isActive ? look.accent : 'rgba(255,255,255,0.08)' },
|
|
||||||
isActive && styles.previewFrameActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Image source={{ uri: look.uri }} style={styles.previewImage} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
!loadingLooks && (
|
!loadingLooks && (
|
||||||
@@ -334,6 +428,19 @@ const styles = StyleSheet.create({
|
|||||||
color: '#FF8A8A',
|
color: '#FF8A8A',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
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: {
|
headerBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Feather } from '@expo/vector-icons';
|
import { Feather } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
|
||||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { UploadCloud } from 'lucide-react';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -10,7 +8,6 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@@ -20,27 +17,12 @@ export const unstable_settings = {
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
import { DynamicForm } from '@/components/forms/dynamic-form';
|
|
||||||
import { BackButton } from '@/components/ui/back-button';
|
import { BackButton } from '@/components/ui/back-button';
|
||||||
|
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
|
||||||
import { getTemplateById } from '@/lib/api/templates';
|
import { getTemplateById } from '@/lib/api/templates';
|
||||||
import { Template } from '@/lib/types/template';
|
import { getUserBalance, chargeUser } from '@/lib/api/balance';
|
||||||
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
|
import { runTemplate } from '@/lib/api/template-runs';
|
||||||
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
|
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
||||||
|
|
||||||
interface Step1FormData {
|
|
||||||
characterImage: string;
|
|
||||||
productImage: string;
|
|
||||||
script: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Step2FormData extends RunTemplateData { }
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
step1: Step1FormData;
|
|
||||||
step2: Step2FormData;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UploadFieldKey = 'characterImage' | 'productImage';
|
|
||||||
|
|
||||||
const FALLBACK_PREVIEW =
|
const FALLBACK_PREVIEW =
|
||||||
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
|
'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 { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
|
||||||
const [template, setTemplate] = useState<Template | null>(null);
|
const [template, setTemplate] = useState<Template | null>(null);
|
||||||
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
const [formData, setFormData] = useState<FormData>({
|
|
||||||
step1: {
|
|
||||||
characterImage: '',
|
|
||||||
productImage: '',
|
|
||||||
script: '',
|
|
||||||
},
|
|
||||||
step2: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -79,169 +50,149 @@ export default function TemplateFormScreen() {
|
|||||||
|
|
||||||
if (templateResponse.success && templateResponse.data) {
|
if (templateResponse.success && templateResponse.data) {
|
||||||
setTemplate(templateResponse.data);
|
setTemplate(templateResponse.data);
|
||||||
|
// 初始化表单数据
|
||||||
if (templateResponse.data.formSchema) {
|
const initialData: Record<string, any> = {};
|
||||||
try {
|
templateResponse.data.formSchema?.startNodes.forEach((node: TemplateGraphNode) => {
|
||||||
const rawData =
|
if (node.data.actionData?.allowMultiple) {
|
||||||
typeof templateResponse.data.formSchema === 'string'
|
initialData[node.id] = [];
|
||||||
? JSON.parse(templateResponse.data.formSchema as string)
|
} else {
|
||||||
: templateResponse.data.formSchema;
|
initialData[node.id] = '';
|
||||||
|
|
||||||
const formConfig = transformWorkflowToFormSchema(rawData);
|
|
||||||
|
|
||||||
if (validateFormSchema(formConfig)) {
|
|
||||||
setFormSchema(formConfig);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse form schema:', error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
setFormData(initialData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load template:', error);
|
console.error('Failed to load template:', error);
|
||||||
Alert.alert('Error', 'Failed to load data. Please retry later.');
|
Alert.alert('错误', '加载数据失败,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateStep1 = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
|
if (!template?.formSchema?.startNodes) return false;
|
||||||
|
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
if (!formData.step1.characterImage) {
|
template.formSchema.startNodes.forEach((node: TemplateGraphNode) => {
|
||||||
newErrors.characterImage = 'Please upload the character image';
|
const { id, data } = node;
|
||||||
}
|
const { actionData, label } = data;
|
||||||
|
const value = formData[id];
|
||||||
|
|
||||||
if (!formData.step1.productImage) {
|
// 检查必填项
|
||||||
newErrors.productImage = 'Please upload the product image';
|
if (actionData?.required) {
|
||||||
}
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
if (!formData.step1.script.trim()) {
|
newErrors[id] = `请选择${label}`;
|
||||||
newErrors.script = 'Please provide the character script';
|
}
|
||||||
}
|
} else if (!value || (typeof value === 'string' && !value.trim())) {
|
||||||
|
newErrors[id] = `请填写${label}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStep1Next = () => {
|
const handleSubmit = async () => {
|
||||||
if (validateStep1()) {
|
|
||||||
setCurrentStep(2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStep2Prev = () => {
|
|
||||||
setCurrentStep(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStep2Submit = async () => {
|
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
|
if (!validateForm()) {
|
||||||
|
Alert.alert('提示', '请完善所有必填项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const combinedData = {
|
// 1. 检查余额
|
||||||
...formData.step1,
|
const balanceResponse = await getUserBalance();
|
||||||
...formData.step2,
|
|
||||||
};
|
|
||||||
|
|
||||||
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: () => {
|
onPress: () => {
|
||||||
router.push(`/templates/${id}/run`);
|
router.push(`/result?generationId=${generationId}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submission failed:', error);
|
console.error('Submission failed:', error);
|
||||||
Alert.alert('Error', 'Submission failed. Please retry later.');
|
Alert.alert('错误', '提交失败,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImagePick = async (field: UploadFieldKey) => {
|
const renderFormFields = () => {
|
||||||
try {
|
if (!template?.formSchema?.startNodes) return null;
|
||||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
||||||
|
|
||||||
if (!permission.granted) {
|
return template.formSchema.startNodes.map((node: TemplateGraphNode) => (
|
||||||
Alert.alert(
|
<DynamicFormField
|
||||||
'Permission Required',
|
key={node.id}
|
||||||
'Please allow photo library access in Settings to upload images.',
|
node={node}
|
||||||
);
|
value={formData[node.id]}
|
||||||
return;
|
onChange={(value) => {
|
||||||
}
|
setFormData(prev => ({ ...prev, [node.id]: value }));
|
||||||
|
if (errors[node.id]) {
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
setErrors(prev => {
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
const newErrors = { ...prev };
|
||||||
allowsEditing: true,
|
delete newErrors[node.id];
|
||||||
quality: 0.9,
|
return newErrors;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (result.canceled || !result.assets || result.assets.length === 0) {
|
}}
|
||||||
return;
|
error={errors[node.id]}
|
||||||
}
|
/>
|
||||||
|
));
|
||||||
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 (
|
|
||||||
<View style={styles.uploadItem}>
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.9}
|
|
||||||
onPress={() => handleImagePick(field)}
|
|
||||||
style={[
|
|
||||||
styles.uploadCard,
|
|
||||||
value && styles.uploadCardFilled,
|
|
||||||
errors[field] && styles.uploadCardError,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{value ? (
|
|
||||||
<Image source={{ uri: value }} style={styles.uploadImage} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View style={styles.uploadIconWrap}>
|
|
||||||
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.uploadLabel}>{label}</Text>
|
|
||||||
<Text style={styles.uploadDescription}>{description}</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
{errors[field] ? (
|
|
||||||
<Text style={styles.inlineError}>{errors[field]}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStep1 = () => {
|
const renderStep1 = () => {
|
||||||
@@ -259,11 +210,10 @@ export default function TemplateFormScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.heroTitle}>
|
<Text style={styles.heroTitle}>
|
||||||
{(template?.title ?? 'Insert your product into an ASMR video').toUpperCase()}
|
{(template?.title ?? 'AI视频生成').toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.heroSubtitle}>
|
<Text style={styles.heroSubtitle}>
|
||||||
{template?.description ||
|
{template?.description || '使用AI技术快速生成专业视频内容'}
|
||||||
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.'}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={styles.previewStage}>
|
<View style={styles.previewStage}>
|
||||||
@@ -273,130 +223,27 @@ export default function TemplateFormScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.uploadRow}>
|
{renderFormFields()}
|
||||||
{renderUploadCard(
|
|
||||||
'characterImage',
|
|
||||||
'UPLOAD CHARACTER IMAGE',
|
|
||||||
'PNG, JPG or Paste from clipboard',
|
|
||||||
)}
|
|
||||||
{renderUploadCard(
|
|
||||||
'productImage',
|
|
||||||
'UPLOAD CHARACTER IMAGE',
|
|
||||||
'PNG, JPG or Paste from clipboard',
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.promptField}>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.promptInput, errors.script && styles.promptInputError]}
|
|
||||||
placeholder="What should your character say?"
|
|
||||||
placeholderTextColor="rgba(255, 255, 255, 0.5)"
|
|
||||||
value={formData.step1.script}
|
|
||||||
onChangeText={text => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
step1: {
|
|
||||||
...prev.step1,
|
|
||||||
script: text,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
if (errors.script) {
|
|
||||||
setErrors(prev => ({
|
|
||||||
...prev,
|
|
||||||
script: '',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
multiline
|
|
||||||
textAlignVertical="top"
|
|
||||||
/>
|
|
||||||
{errors.script ? <Text style={styles.inlineError}>{errors.script}</Text> : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.generateButton}
|
style={[styles.generateButton, isSubmitting && styles.generateButtonDisabled]}
|
||||||
activeOpacity={0.88}
|
activeOpacity={0.88}
|
||||||
onPress={handleStep1Next}
|
onPress={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<Feather name="star" size={20} color="#050505" />
|
{isSubmitting ? (
|
||||||
<Text style={styles.generateLabel}>Generate Video</Text>
|
<ActivityIndicator size="small" color="#050505" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Feather name="star" size={20} color="#050505" />
|
||||||
|
<Text style={styles.generateLabel}>生成视频</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStep2 = () => (
|
|
||||||
<View style={styles.step2Wrapper}>
|
|
||||||
<View style={styles.topBar}>
|
|
||||||
<BackButton onPress={handleStep2Prev} />
|
|
||||||
<Text style={styles.stepIndicator}>STEP 2 OF 2</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.step2Title}>Run Parameters</Text>
|
|
||||||
<Text style={styles.step2Subtitle}>
|
|
||||||
Fine-tune the details so the ASMR video delivers the mood you expect.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={styles.step2FormShell}>
|
|
||||||
{formSchema.fields.length > 0 ? (
|
|
||||||
<DynamicForm
|
|
||||||
schema={formSchema}
|
|
||||||
initialData={formData.step2}
|
|
||||||
onDataChange={data => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
step2: data,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={styles.emptyIcon}>📋</Text>
|
|
||||||
<Text style={styles.emptyText}>No extra parameters</Text>
|
|
||||||
<Text style={styles.emptyDescription}>
|
|
||||||
This template is ready to go—start generating the ASMR experience instantly.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBottomActions = () => {
|
|
||||||
if (currentStep !== 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView edges={['bottom']} style={styles.bottomSafeArea}>
|
|
||||||
<View style={styles.bottomActions}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.secondaryButton}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
onPress={handleStep2Prev}
|
|
||||||
>
|
|
||||||
<Text style={styles.secondaryLabel}>Back</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.submitButton}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
onPress={handleStep2Submit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<ActivityIndicator color="#050505" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Feather name="zap" size={18} color="#050505" />
|
|
||||||
<Text style={styles.submitLabel}>Generate Video</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -420,9 +267,8 @@ export default function TemplateFormScreen() {
|
|||||||
|
|
||||||
<View style={styles.canvas}>
|
<View style={styles.canvas}>
|
||||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
||||||
{currentStep === 1 ? renderStep1() : renderStep2()}
|
{renderStep1()}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
{renderBottomActions()}
|
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -512,86 +358,6 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '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: {
|
generateButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -601,100 +367,13 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#D1FF00',
|
backgroundColor: '#D1FF00',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
},
|
},
|
||||||
|
generateButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
generateLabel: {
|
generateLabel: {
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
color: '#050505',
|
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
391
components/forms/dynamic-form-field.tsx
Normal file
391
components/forms/dynamic-form-field.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||||
|
|
||||||
|
<View style={styles.selectOptions}>
|
||||||
|
{options.map((option: any, index: number) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.selectOption,
|
||||||
|
isSelected(option.value) && styles.selectOptionSelected,
|
||||||
|
error && styles.selectOptionError,
|
||||||
|
]}
|
||||||
|
onPress={() => handleSelect(option.value)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.selectOptionText,
|
||||||
|
isSelected(option.value) && styles.selectOptionTextSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{isSelected(option.value) && (
|
||||||
|
<Feather name="check" size={18} color="#050505" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTextField = () => {
|
||||||
|
const placeholder = actionData?.placeholder || '请输入内容';
|
||||||
|
const multiline = actionData?.multiline || false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.textInput,
|
||||||
|
multiline && styles.textInputMultiline,
|
||||||
|
error && styles.textInputError,
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="rgba(255, 255, 255, 0.5)"
|
||||||
|
value={value || ''}
|
||||||
|
onChangeText={onChange}
|
||||||
|
multiline={multiline}
|
||||||
|
textAlignVertical={multiline ? 'top' : 'center'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.uploadCard,
|
||||||
|
value && styles.uploadCardFilled,
|
||||||
|
error && styles.uploadCardError,
|
||||||
|
]}
|
||||||
|
onPress={handleImagePick}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<Image source={{ uri: value }} style={styles.uploadImage} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.uploadIconWrap}>
|
||||||
|
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.uploadLabel}>上传图片</Text>
|
||||||
|
<Text style={styles.uploadDescription}>
|
||||||
|
{actionData?.placeholder || 'PNG, JPG 格式'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.uploadCard,
|
||||||
|
value && styles.uploadCardFilled,
|
||||||
|
error && styles.uploadCardError,
|
||||||
|
]}
|
||||||
|
onPress={handleVideoPick}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<View style={styles.videoPreview}>
|
||||||
|
<Feather name="video" size={40} color="#D1FF00" />
|
||||||
|
<Text style={styles.videoPreviewText}>视频已选择</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.uploadIconWrap}>
|
||||||
|
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.uploadLabel}>上传视频</Text>
|
||||||
|
<Text style={styles.uploadDescription}>
|
||||||
|
{actionData?.placeholder || 'MP4, MOV 格式'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ export function DynamicForm({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
}
|
}
|
||||||
}, [schema, initialData, onDataChange]);
|
}, [schema, initialData]);
|
||||||
|
|
||||||
// 验证单个字段
|
// 验证单个字段
|
||||||
const validateField = (field: FormFieldSchema, value: any): string | null => {
|
const validateField = (field: FormFieldSchema, value: any): string | null => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ThemedText } from '@/components/themed-text';
|
import { ThemedText } from '@/components/themed-text';
|
||||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -43,10 +43,15 @@ export function FullscreenVideoModal({
|
|||||||
hasNext = false,
|
hasNext = false,
|
||||||
hasPrevious = false,
|
hasPrevious = false,
|
||||||
}: FullscreenVideoModalProps) {
|
}: FullscreenVideoModalProps) {
|
||||||
const player = useVideoPlayer(
|
// 使用useRef存储player实例,避免无限循环
|
||||||
{
|
const playerRef = useRef<any>(null);
|
||||||
uri: videoUrl,
|
|
||||||
},
|
// 稳定videoUrl,使用useMemo
|
||||||
|
const stableVideoUrl = useMemo(() => ({ uri: videoUrl }), [videoUrl]);
|
||||||
|
|
||||||
|
// 始终调用useVideoPlayer,但只在videoUrl变化时更新player
|
||||||
|
const newPlayer = useVideoPlayer(
|
||||||
|
stableVideoUrl,
|
||||||
(player) => {
|
(player) => {
|
||||||
player.loop = isLooping;
|
player.loop = isLooping;
|
||||||
player.muted = isMuted;
|
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);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// 重置状态当模态框打开/关闭时
|
// 重置状态当模态框打开/关闭时
|
||||||
@@ -63,8 +76,9 @@ export function FullscreenVideoModal({
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
} else {
|
} else {
|
||||||
if (player) {
|
const currentPlayer = playerRef.current;
|
||||||
player.pause();
|
if (currentPlayer) {
|
||||||
|
currentPlayer.pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [visible, autoPlay]);
|
}, [visible, autoPlay]);
|
||||||
@@ -73,7 +87,8 @@ export function FullscreenVideoModal({
|
|||||||
const handleVideoReady = () => {
|
const handleVideoReady = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
player?.play();
|
const currentPlayer = playerRef.current;
|
||||||
|
currentPlayer?.play();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,9 +175,8 @@ export function FullscreenVideoModal({
|
|||||||
<VideoView
|
<VideoView
|
||||||
player={player}
|
player={player}
|
||||||
style={styles.video}
|
style={styles.video}
|
||||||
contentFit="contain" as any
|
contentFit="contain"
|
||||||
nativeControls={false}
|
nativeControls={false}
|
||||||
onError={handleVideoError}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ThemedView } from '@/components/themed-view';
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
import {
|
import {
|
||||||
calculateOptimalVideoSize,
|
calculateOptimalVideoSize,
|
||||||
getVideoFileType,
|
getVideoFileType,
|
||||||
isValidVideoUrl
|
isValidVideoUrl
|
||||||
} from '@/utils/video-utils';
|
} 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 兼容映射
|
// ResizeMode 兼容映射
|
||||||
const ResizeMode = {
|
const ResizeMode = {
|
||||||
@@ -78,32 +77,48 @@ export function VideoPlayer({
|
|||||||
onPress,
|
onPress,
|
||||||
fullscreenMode = false,
|
fullscreenMode = false,
|
||||||
}: VideoPlayerProps) {
|
}: VideoPlayerProps) {
|
||||||
// 使用新的 expo-video API - 使用空的初始化回调,避免依赖外部变量
|
// 使用useMemo稳定source对象,避免不必要的重新渲染
|
||||||
const player = useVideoPlayer(
|
const stableSource = useMemo(() => ({ uri: source.uri }), [source.uri]);
|
||||||
{
|
|
||||||
uri: source.uri,
|
// 使用useRef存储player实例,避免无限循环
|
||||||
},
|
const playerRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// 始终调用useVideoPlayer,但只在source变化时更新player
|
||||||
|
const newPlayer = useVideoPlayer(
|
||||||
|
stableSource,
|
||||||
(player) => {
|
(player) => {
|
||||||
// 空初始化回调,避免每次渲染时重新创建
|
// 空初始化回调
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 只有当newPlayer变化时才更新player
|
||||||
|
useEffect(() => {
|
||||||
|
playerRef.current = newPlayer;
|
||||||
|
}, [newPlayer]);
|
||||||
|
|
||||||
// 单独处理播放属性变化
|
// 单独处理播放属性变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const player = playerRef.current;
|
||||||
if (player) {
|
if (player) {
|
||||||
player.loop = isLooping;
|
player.loop = isLooping;
|
||||||
player.muted = isMuted;
|
player.muted = isMuted;
|
||||||
}
|
}
|
||||||
}, [player, isLooping, isMuted]);
|
}, [isLooping, isMuted]);
|
||||||
|
|
||||||
// 处理自动播放
|
// 处理自动播放
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (player && autoPlay && shouldPlay) {
|
const player = playerRef.current;
|
||||||
player.play();
|
if (player) {
|
||||||
} else if (player && !shouldPlay) {
|
if (autoPlay && shouldPlay) {
|
||||||
player.pause();
|
player.play();
|
||||||
|
} else if (!shouldPlay) {
|
||||||
|
player.pause();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [player, autoPlay, shouldPlay]);
|
}, [autoPlay, shouldPlay]);
|
||||||
|
|
||||||
|
// 获取当前的player实例
|
||||||
|
const player = playerRef.current || newPlayer;
|
||||||
|
|
||||||
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -282,7 +297,6 @@ export function VideoPlayer({
|
|||||||
style={styles.video}
|
style={styles.video}
|
||||||
contentFit={resizeMode as any}
|
contentFit={resizeMode as any}
|
||||||
nativeControls={useNativeControls}
|
nativeControls={useNativeControls}
|
||||||
onError={handleVideoError}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
50
lib/api/balance.ts
Normal file
50
lib/api/balance.ts
Normal file
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeTransaction {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
amount: number;
|
||||||
|
type: 'charge' | 'refund';
|
||||||
|
description?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: ChargeTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户余额
|
||||||
|
*/
|
||||||
|
export async function getUserBalance(): Promise<BalanceResponse> {
|
||||||
|
return apiClient<BalanceResponse>('/api/users/balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扣费
|
||||||
|
*/
|
||||||
|
export async function chargeUser(data: ChargeRequest): Promise<ChargeResponse> {
|
||||||
|
return apiClient<ChargeResponse>('/api/users/charge', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,6 +38,13 @@ export interface TemplateNodeActionData {
|
|||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
selectedModel?: string;
|
selectedModel?: string;
|
||||||
advancedParams?: Record<string, string | number | boolean | null>;
|
advancedParams?: Record<string, string | number | boolean | null>;
|
||||||
|
// Select field
|
||||||
|
options?: Array<{ label: string; value: string }>;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
// Text field
|
||||||
|
multiline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateNodeMetrics {
|
export interface TemplateNodeMetrics {
|
||||||
@@ -129,6 +136,8 @@ export interface Template {
|
|||||||
uploadSapecifications?: string | null;
|
uploadSapecifications?: string | null;
|
||||||
uploadSapecificationsEn?: string | null;
|
uploadSapecificationsEn?: string | null;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
costPrice?: number | null;
|
||||||
|
price?: number | null;
|
||||||
category: Category | null;
|
category: Category | null;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
templateTags?: TemplateTagLink[];
|
templateTags?: TemplateTagLink[];
|
||||||
|
|||||||
Reference in New Issue
Block a user