fix: 修复布局问题
This commit is contained in:
@@ -1,14 +1,41 @@
|
|||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { ActivityIndicator, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
RefreshControl,
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity
|
||||||
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
|
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
interface GroupedData {
|
interface GroupedData {
|
||||||
[date: string]: TemplateGeneration[];
|
[date: string]: TemplateGeneration[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件 URL 后缀名判断媒体类型
|
||||||
|
*/
|
||||||
|
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||||
|
if (!url) return 'unknown';
|
||||||
|
|
||||||
|
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||||
|
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||||
|
|
||||||
|
if (imageExts.includes(extension)) return 'image';
|
||||||
|
if (videoExts.includes(extension)) return 'video';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
const groupByDate = (data: TemplateGeneration[]): GroupedData => {
|
const groupByDate = (data: TemplateGeneration[]): GroupedData => {
|
||||||
const groups: GroupedData = {};
|
const groups: GroupedData = {};
|
||||||
|
|
||||||
@@ -115,30 +142,127 @@ const getStatusText = (status: string): string => {
|
|||||||
export default function HistoryScreen() {
|
export default function HistoryScreen() {
|
||||||
const [groupedData, setGroupedData] = useState<GroupedData>({});
|
const [groupedData, setGroupedData] = useState<GroupedData>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchData = async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
try {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else if (pageNum === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await getTemplateGenerations({ limit: 100 });
|
} else {
|
||||||
|
setLoadingMore(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getTemplateGenerations({ page: pageNum, limit: 20 });
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const grouped = groupByDate(response.data.generations);
|
const newGenerations = response.data.generations;
|
||||||
|
|
||||||
|
if (isRefresh || pageNum === 1) {
|
||||||
|
setAllGenerations(newGenerations);
|
||||||
|
setPage(1);
|
||||||
|
} else {
|
||||||
|
setAllGenerations(prev => [...prev, ...newGenerations]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(newGenerations.length === 20);
|
||||||
|
const grouped = groupByDate(isRefresh || pageNum === 1 ? newGenerations : [...allGenerations, ...newGenerations]);
|
||||||
setGroupedData(grouped);
|
setGroupedData(grouped);
|
||||||
|
setError(null);
|
||||||
} else {
|
} else {
|
||||||
setError('获取数据失败');
|
setError('获取数据失败');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch data:', err);
|
||||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
useEffect(() => {
|
||||||
|
fetchData(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
fetchData(1, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!loadingMore && hasMore && !loading) {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
setPage(nextPage);
|
||||||
|
fetchData(nextPage, false);
|
||||||
|
}
|
||||||
|
}, [loadingMore, hasMore, page, loading]);
|
||||||
|
|
||||||
|
const handleScroll = (event: any) => {
|
||||||
|
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
||||||
|
const paddingToBottom = 20;
|
||||||
|
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;
|
||||||
|
|
||||||
|
if (isCloseToBottom && !loadingMore && hasMore) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMediaItem = (item: TemplateGeneration) => {
|
||||||
|
const url = item.resultUrl && item.resultUrl.length > 0 ? item.resultUrl[0] : null;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.placeholderImage]}>
|
||||||
|
<Text style={styles.placeholderText}>
|
||||||
|
{item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = getMediaType(url);
|
||||||
|
|
||||||
|
if (mediaType === 'video') {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: getImageHeight(item.type),
|
||||||
|
objectFit: 'cover' as any,
|
||||||
|
}}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.videoPlaceholder]}>
|
||||||
|
<Text style={styles.videoIcon}>🎬</Text>
|
||||||
|
<Text style={styles.videoText}>视频</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
style={[styles.image, { height: getImageHeight(item.type) }]}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
|
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
|
||||||
@@ -173,6 +297,16 @@ export default function HistoryScreen() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor="#FFFFFF"
|
||||||
|
colors={['#FFFFFF']}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
scrollEventThrottle={400}
|
||||||
>
|
>
|
||||||
<Text style={styles.heading}>Content Generation</Text>
|
<Text style={styles.heading}>Content Generation</Text>
|
||||||
|
|
||||||
@@ -190,19 +324,13 @@ export default function HistoryScreen() {
|
|||||||
<View style={styles.gallery}>
|
<View style={styles.gallery}>
|
||||||
<View style={styles.leadingLane}>
|
<View style={styles.leadingLane}>
|
||||||
{leftColumn.map(item => (
|
{leftColumn.map(item => (
|
||||||
<View key={item.id} style={styles.frame}>
|
<TouchableOpacity
|
||||||
{item.resultUrl && item.resultUrl.length > 0 ? (
|
key={item.id}
|
||||||
<Image
|
style={styles.frame}
|
||||||
source={{ uri: item.resultUrl[0] }}
|
onPress={() => router.push(`/result?generationId=${item.id}`)}
|
||||||
style={[styles.image, { height: getImageHeight(item.type) }]}
|
activeOpacity={0.9}
|
||||||
/>
|
>
|
||||||
) : (
|
{renderMediaItem(item)}
|
||||||
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.placeholderImage]}>
|
|
||||||
<Text style={styles.placeholderText}>
|
|
||||||
{item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={styles.statusBadge}>
|
<View style={styles.statusBadge}>
|
||||||
@@ -211,25 +339,19 @@ export default function HistoryScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
|
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.trailingLane}>
|
<View style={styles.trailingLane}>
|
||||||
{rightColumn.map(item => (
|
{rightColumn.map(item => (
|
||||||
<View key={item.id} style={styles.frame}>
|
<TouchableOpacity
|
||||||
{item.resultUrl && item.resultUrl.length > 0 ? (
|
key={item.id}
|
||||||
<Image
|
style={styles.frame}
|
||||||
source={{ uri: item.resultUrl[0] }}
|
onPress={() => router.push(`/result?generationId=${item.id}`)}
|
||||||
style={[styles.image, { height: getImageHeight(item.type) }]}
|
activeOpacity={0.9}
|
||||||
/>
|
>
|
||||||
) : (
|
{renderMediaItem(item)}
|
||||||
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.placeholderImage]}>
|
|
||||||
<Text style={styles.placeholderText}>
|
|
||||||
{item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={styles.statusBadge}>
|
<View style={styles.statusBadge}>
|
||||||
@@ -238,7 +360,7 @@ export default function HistoryScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
|
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -246,6 +368,19 @@ export default function HistoryScreen() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loadingMore && (
|
||||||
|
<View style={styles.loadingMoreContainer}>
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
<Text style={styles.loadingMoreText}>加载更多...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && allGenerations.length > 0 && (
|
||||||
|
<View style={styles.noMoreContainer}>
|
||||||
|
<Text style={styles.noMoreText}>没有更多内容了</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
@@ -292,13 +427,13 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
frame: {
|
frame: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderRadius: 28,
|
borderRadius: 16,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderRadius: 28,
|
|
||||||
},
|
},
|
||||||
placeholderImage: {
|
placeholderImage: {
|
||||||
backgroundColor: '#2A2A2A',
|
backgroundColor: '#2A2A2A',
|
||||||
@@ -308,15 +443,28 @@ const styles = StyleSheet.create({
|
|||||||
placeholderText: {
|
placeholderText: {
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
},
|
},
|
||||||
|
videoPlaceholder: {
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
videoIcon: {
|
||||||
|
fontSize: 48,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
videoText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#9E9E9E',
|
||||||
|
},
|
||||||
overlay: {
|
overlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
borderBottomLeftRadius: 28,
|
borderBottomLeftRadius: 16,
|
||||||
borderBottomRightRadius: 28,
|
borderBottomRightRadius: 16,
|
||||||
},
|
},
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -374,4 +522,23 @@ const styles = StyleSheet.create({
|
|||||||
dateGroup: {
|
dateGroup: {
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
|
loadingMoreContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
loadingMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#9E9E9E',
|
||||||
|
},
|
||||||
|
noMoreContainer: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
noMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export default function RootLayout() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||||
|
<Stack.Screen name="result" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="exchange" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="recharge" options={{ headerShown: false }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
310
app/recharge.tsx
310
app/recharge.tsx
@@ -1,310 +0,0 @@
|
|||||||
import { router } from 'expo-router';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import { X, Zap } from 'lucide-react';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
|
|
||||||
const pointBundles = [
|
|
||||||
{ id: 'bundle-500', amount: 500, price: 35 },
|
|
||||||
{ id: 'bundle-2000', amount: 2000, price: 140 },
|
|
||||||
{ id: 'bundle-5000', amount: 5000, price: 350 },
|
|
||||||
{ id: 'bundle-10000', amount: 10000, price: 700 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function RechargeScreen() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleBundleSelection = (bundleId: string) => {
|
|
||||||
setSelectedBundleId(bundleId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePurchase = () => {
|
|
||||||
if (!selectedBundleId) {
|
|
||||||
Alert.alert(
|
|
||||||
'Choose a pack',
|
|
||||||
'Select the points pack that fits your journey before proceeding.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedBundle = pointBundles.find(bundle => bundle.id === selectedBundleId);
|
|
||||||
if (!selectedBundle) {
|
|
||||||
Alert.alert('Unavailable pack', 'The chosen points pack is no longer available.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
'Purchase in progress',
|
|
||||||
`Preparing ${selectedBundle.amount} points for ¥${selectedBundle.price}.`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
|
|
||||||
<StatusBar style="light" />
|
|
||||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
|
||||||
<View style={styles.frame}>
|
|
||||||
<ScrollView
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
>
|
|
||||||
<View style={styles.headerRow}>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Close recharge"
|
|
||||||
onPress={() => router.back()}
|
|
||||||
style={styles.dismissButton}
|
|
||||||
>
|
|
||||||
<X color="#FFFFFF" size={22} strokeWidth={2.2} />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="View points details"
|
|
||||||
onPress={() => router.push('/points')}
|
|
||||||
style={styles.pointsDetails}
|
|
||||||
>
|
|
||||||
<Text style={styles.pointsDetailsText}>Points Details</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.balanceBlock}>
|
|
||||||
<View style={styles.balanceRow}>
|
|
||||||
<Zap color="#FFCE38" size={30} strokeWidth={2.2} />
|
|
||||||
<Text style={styles.balanceValue}>60</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.balanceCaption}>No active subscription plans</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.tabStrip}>
|
|
||||||
<Text style={styles.tabInactive}>Subscription</Text>
|
|
||||||
<View style={styles.tabActive}>
|
|
||||||
<Text style={styles.tabActiveText}>points pack</Text>
|
|
||||||
<View style={styles.tabIndicator} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.bundleGrid}>
|
|
||||||
{pointBundles.map(bundle => (
|
|
||||||
<Pressable
|
|
||||||
key={bundle.id}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Purchase ${bundle.amount} points for ¥${bundle.price}`}
|
|
||||||
onPress={() => handleBundleSelection(bundle.id)}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.bundleCard,
|
|
||||||
selectedBundleId === bundle.id && styles.bundleCardActive,
|
|
||||||
pressed && styles.bundleCardPressed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.bundleHeader}>
|
|
||||||
<Zap color="#FFCE38" size={22} strokeWidth={2.2} />
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.bundleAmount,
|
|
||||||
selectedBundleId === bundle.id && styles.bundleAmountActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{bundle.amount}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.bundlePrice,
|
|
||||||
selectedBundleId === bundle.id && styles.bundlePriceActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
¥ {bundle.price}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Purchase points"
|
|
||||||
onPress={handlePurchase}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.purchaseButton,
|
|
||||||
pressed && styles.purchaseButtonPressed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={styles.purchaseButtonText}>Purchase points</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
canvas: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
frame: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingBottom: 32,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
marginTop: 12,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
dismissButton: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
pointsDetails: {
|
|
||||||
backgroundColor: '#181818',
|
|
||||||
borderRadius: 20,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2C2C2C',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
pointsDetailsText: {
|
|
||||||
color: '#F1F1F1',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
balanceBlock: {
|
|
||||||
marginTop: 32,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
balanceRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
balanceValue: {
|
|
||||||
marginLeft: 12,
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
balanceCaption: {
|
|
||||||
marginTop: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#8E8E8E',
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
},
|
|
||||||
tabStrip: {
|
|
||||||
marginTop: 40,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
},
|
|
||||||
tabInactive: {
|
|
||||||
marginRight: 32,
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#555555',
|
|
||||||
fontWeight: '500',
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
},
|
|
||||||
tabActive: {
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
tabActiveText: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '600',
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
},
|
|
||||||
tabIndicator: {
|
|
||||||
marginTop: 10,
|
|
||||||
height: 2,
|
|
||||||
width: 70,
|
|
||||||
backgroundColor: '#FFD84E',
|
|
||||||
borderRadius: 2,
|
|
||||||
},
|
|
||||||
bundleGrid: {
|
|
||||||
marginTop: 32,
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
bundleCard: {
|
|
||||||
width: '47%',
|
|
||||||
backgroundColor: '#111111',
|
|
||||||
borderRadius: 24,
|
|
||||||
paddingVertical: 24,
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#1F1F1F',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
bundleCardActive: {
|
|
||||||
borderColor: '#FFD84E',
|
|
||||||
},
|
|
||||||
bundleCardPressed: {
|
|
||||||
transform: [{ scale: 0.98 }],
|
|
||||||
},
|
|
||||||
bundleHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
bundleAmount: {
|
|
||||||
marginLeft: 12,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
bundleAmountActive: {
|
|
||||||
color: '#FFD84E',
|
|
||||||
},
|
|
||||||
bundlePrice: {
|
|
||||||
marginTop: 24,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#A4A4A4',
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
},
|
|
||||||
bundlePriceActive: {
|
|
||||||
color: '#FFD84E',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
paddingTop: 16,
|
|
||||||
},
|
|
||||||
purchaseButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: '#CFFF21',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
purchaseButtonPressed: {
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
purchaseButtonText: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#101010',
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
467
app/result.tsx
467
app/result.tsx
@@ -1,8 +1,10 @@
|
|||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { getTemplateGeneration } from '@/lib/api/template-runs';
|
||||||
|
|
||||||
// ResizeMode 兼容映射 - 移除 'stretch',expo-video 不支持
|
// ResizeMode 兼容映射 - 移除 'stretch',expo-video 不支持
|
||||||
const ResizeMode = {
|
const ResizeMode = {
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Image,
|
Image,
|
||||||
@@ -34,11 +37,12 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
|
FlatList,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||||
const HERO_HEIGHT = screenWidth * 0.58;
|
const HERO_HEIGHT = screenWidth * 0.58;
|
||||||
const DETAIL_HEIGHT = screenWidth * 0.95;
|
const VIDEO_HEIGHT = screenHeight * 0.6;
|
||||||
|
|
||||||
interface ResultWithTemplate {
|
interface ResultWithTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,87 +55,64 @@ interface ResultWithTemplate {
|
|||||||
creditsTransactionId?: string;
|
creditsTransactionId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
templateName?: string;
|
template?: {
|
||||||
templateThumbnail?: string;
|
id: string;
|
||||||
templateDescription?: string;
|
title: string;
|
||||||
|
titleEn: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件 URL 后缀名判断媒体类型
|
||||||
|
*/
|
||||||
|
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||||
|
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||||
|
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||||
|
|
||||||
|
if (imageExts.includes(extension)) return 'image';
|
||||||
|
if (videoExts.includes(extension)) return 'video';
|
||||||
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultPage() {
|
export default function ResultPage() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { generationId } = useLocalSearchParams<{ generationId: string }>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [result, setResult] = useState<ResultWithTemplate | null>(null);
|
const [result, setResult] = useState<ResultWithTemplate | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [fullscreenVisible, setFullscreenVisible] = useState(false);
|
const [fullscreenVisible, setFullscreenVisible] = useState(false);
|
||||||
const [fullscreenContent, setFullscreenContent] = useState<{
|
const [fullscreenContent, setFullscreenContent] = useState<{
|
||||||
type: 'image' | 'video';
|
type: 'image' | 'video';
|
||||||
url: string;
|
url: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (generationId) {
|
||||||
loadResult();
|
loadResult();
|
||||||
}, [id]);
|
}
|
||||||
|
}, [generationId]);
|
||||||
|
|
||||||
const loadResult = async () => {
|
const loadResult = async () => {
|
||||||
|
if (!generationId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const typeParam = typeof id === 'string' ? id.split('_')[1] : 'text';
|
setLoading(true);
|
||||||
const resultId = typeof id === 'string' ? id : 'res1_text';
|
const response = await getTemplateGeneration(generationId);
|
||||||
|
|
||||||
const getResultByType = () => {
|
if (response.success && response.data) {
|
||||||
switch (typeParam) {
|
setResult(response.data);
|
||||||
case 'image':
|
} else {
|
||||||
return {
|
|
||||||
id: resultId,
|
|
||||||
userId: 'user1',
|
|
||||||
templateId: 'tpl2',
|
|
||||||
type: 'IMAGE' as const,
|
|
||||||
resultUrl: ['https://picsum.photos/800/600?random=1'],
|
|
||||||
status: 'completed' as const,
|
|
||||||
creditsCost: 20,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
templateName: 'AI图片生成',
|
|
||||||
templateThumbnail: 'https://picsum.photos/60/60?random=2',
|
|
||||||
templateDescription: '使用AI生成高质量图片',
|
|
||||||
};
|
|
||||||
case 'video':
|
|
||||||
return {
|
|
||||||
id: resultId,
|
|
||||||
userId: 'user1',
|
|
||||||
templateId: 'tpl3',
|
|
||||||
type: 'VIDEO' as const,
|
|
||||||
resultUrl: ['https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'],
|
|
||||||
status: 'completed' as const,
|
|
||||||
creditsCost: 50,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
templateName: 'AI视频生成',
|
|
||||||
templateThumbnail: 'https://picsum.photos/60/60?random=3',
|
|
||||||
templateDescription: '基于文本生成视频内容',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
id: resultId,
|
|
||||||
userId: 'user1',
|
|
||||||
templateId: 'tpl1',
|
|
||||||
type: 'TEXT' as const,
|
|
||||||
resultUrl: [
|
|
||||||
'这是一段AI生成的营销文案,专门为您的产品精心打造。它突出了产品的核心优势,采用了吸引人的语言风格,能够有效提升用户的购买欲望和品牌认知度。通过深入分析目标受众的需求和痛点,我们精心设计了这份文案,旨在最大化品牌影响力和转化效果。',
|
|
||||||
],
|
|
||||||
status: 'completed' as const,
|
|
||||||
creditsCost: 10,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
templateName: 'AI文案生成',
|
|
||||||
templateThumbnail: 'https://picsum.photos/60/60?random=1',
|
|
||||||
templateDescription: '基于关键词生成营销文案',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setResult(getResultByType());
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('错误', '无法加载结果详情');
|
Alert.alert('错误', '无法加载结果详情');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load result:', error);
|
||||||
|
Alert.alert('错误', '加载结果失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRerun = () => {
|
const handleRerun = () => {
|
||||||
@@ -139,22 +120,112 @@ export default function ResultPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveToGallery = async () => {
|
const handleSaveToGallery = async () => {
|
||||||
if (!result || result.type === 'TEXT') return;
|
console.log('handleSaveToGallery called');
|
||||||
|
if (!result || result.type === 'TEXT') {
|
||||||
|
console.log('Skipping: no result or text type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
console.log('Platform:', Platform.OS);
|
||||||
|
console.log('URLs to download:', result.resultUrl);
|
||||||
|
|
||||||
|
// Web 平台处理
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
console.log('Using web download method');
|
||||||
|
for (const url of result.resultUrl) {
|
||||||
|
try {
|
||||||
|
console.log('Downloading:', url);
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// 类型安全的处理方式
|
||||||
|
if (typeof window !== 'undefined' && window.URL && window.URL.createObjectURL) {
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = url.split('/').pop() || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
console.log('Download triggered for:', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加延迟,避免浏览器阻止多个下载
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Web download failed:', url, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = result.resultUrl.length;
|
||||||
|
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
|
||||||
|
Alert.alert(
|
||||||
|
'下载开始',
|
||||||
|
count > 1 ? `正在下载${count}个${mediaType}` : `正在下载${mediaType}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原生平台处理
|
||||||
|
console.log('Using native download method');
|
||||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||||
|
console.log('Permission status:', status);
|
||||||
|
|
||||||
if (status !== 'granted') {
|
if (status !== 'granted') {
|
||||||
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
|
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Alert.alert('保存成功', `${result.type === 'IMAGE' ? '图片' : '视频'}已保存到相册`);
|
// 批量下载所有媒体文件
|
||||||
|
let successCount = 0;
|
||||||
|
for (const url of result.resultUrl) {
|
||||||
|
try {
|
||||||
|
// 生成临时文件名
|
||||||
|
const filename = url.split('/').pop() || `download_${Date.now()}`;
|
||||||
|
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
||||||
|
|
||||||
|
// 下载文件到本地
|
||||||
|
console.log('Downloading:', url, 'to', fileUri);
|
||||||
|
const downloadResult = await FileSystem.downloadAsync(url, fileUri);
|
||||||
|
|
||||||
|
if (downloadResult.status === 200) {
|
||||||
|
// 保存到相册
|
||||||
|
const asset = await MediaLibrary.createAssetAsync(downloadResult.uri);
|
||||||
|
console.log('Saved to library:', asset.uri);
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
console.error('Download failed with status:', downloadResult.status);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Failed to download and save:', url, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = result.resultUrl.length;
|
||||||
|
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
|
||||||
|
|
||||||
|
if (successCount === count) {
|
||||||
|
Alert.alert(
|
||||||
|
'保存成功',
|
||||||
|
count > 1 ? `${count}个${mediaType}已保存到相册` : `${mediaType}已保存到相册`
|
||||||
|
);
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
Alert.alert(
|
||||||
|
'部分保存成功',
|
||||||
|
`${successCount}/${count}个${mediaType}已保存到相册`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
|
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save to gallery error:', error);
|
||||||
|
Alert.alert('保存失败', '无法保存文件,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
console.log('handleSaveToGallery completed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +246,14 @@ export default function ResultPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadPress = () => {
|
const handleDownloadPress = () => {
|
||||||
if (!result) return;
|
console.log('Download button pressed');
|
||||||
|
if (!result) {
|
||||||
|
console.log('No result available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Result type:', result.type);
|
||||||
|
console.log('Result URLs:', result.resultUrl);
|
||||||
|
|
||||||
if (result.type === 'TEXT') {
|
if (result.type === 'TEXT') {
|
||||||
handleCopyText();
|
handleCopyText();
|
||||||
@@ -185,57 +263,69 @@ export default function ResultPage() {
|
|||||||
handleSaveToGallery();
|
handleSaveToGallery();
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderVisualMedia = (
|
if (loading) {
|
||||||
url: string,
|
|
||||||
mediaStyles: { image: StyleProp<ImageStyle>; video: StyleProp<ViewStyle> }
|
|
||||||
) => {
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
if (result?.type === 'VIDEO') {
|
|
||||||
// 简化实现:使用原生 HTML5 video 或 Image 作为回退
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
return (
|
|
||||||
<video
|
|
||||||
src={url}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover' as any
|
|
||||||
}}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 原生平台使用简化的 VideoView 实现
|
|
||||||
return (
|
|
||||||
<View style={mediaStyles.video}>
|
|
||||||
<ThemedText>Video: {url}</ThemedText>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
source={{ uri: url }}
|
|
||||||
style={mediaStyles.image}
|
|
||||||
resizeMode="cover"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.screen}>
|
<ThemedView style={styles.screen}>
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#D1FF00" />
|
||||||
<Text style={styles.loadingText}>加载中...</Text>
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.screen}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>未找到结果</Text>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isTextResult = result.type === 'TEXT';
|
const isTextResult = result.type === 'TEXT';
|
||||||
const primaryPreviewUrl = result.resultUrl[0] ?? '';
|
const mediaUrls = result.resultUrl || [];
|
||||||
|
const hasMultipleMedia = mediaUrls.length > 1;
|
||||||
|
|
||||||
|
const renderMediaItem = (url: string, index: number) => {
|
||||||
|
const mediaType = getMediaType(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={`media-${index}`} style={{ width: screenWidth, backgroundColor: '#000' }}>
|
||||||
|
{mediaType === 'video' ? (
|
||||||
|
Platform.OS === 'web' ? (
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: VIDEO_HEIGHT,
|
||||||
|
objectFit: 'contain' as any,
|
||||||
|
}}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ width: '100%', height: VIDEO_HEIGHT }}>
|
||||||
|
<ThemedText>Video: {url}</ThemedText>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
style={{ width: '100%', height: VIDEO_HEIGHT }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = (event: any) => {
|
||||||
|
const offsetX = event.nativeEvent.contentOffset.x;
|
||||||
|
const index = Math.round(offsetX / screenWidth);
|
||||||
|
setCurrentMediaIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.screen}>
|
<ThemedView style={styles.screen}>
|
||||||
@@ -245,6 +335,7 @@ export default function ResultPage() {
|
|||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
<View style={styles.contentPadding}>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
@@ -256,51 +347,59 @@ export default function ResultPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.title}>
|
<Text style={styles.title}>
|
||||||
INSEAR YOUR PRODUCT INTO AN ASMR VIDEO
|
{result.template?.title || '生成结果'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.
|
{result.template?.titleEn || ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.heroCard}>
|
|
||||||
{isTextResult ? (
|
{isTextResult ? (
|
||||||
|
<View style={styles.heroCard}>
|
||||||
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
|
||||||
<Text style={styles.generatedText}>{primaryPreviewUrl}</Text>
|
<Text style={styles.generatedText}>{mediaUrls[0]}</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<View style={styles.mediaContainer}>
|
||||||
{renderVisualMedia(primaryPreviewUrl, {
|
<ScrollView
|
||||||
image: styles.heroMedia,
|
horizontal
|
||||||
video: styles.heroMedia,
|
pagingEnabled
|
||||||
})}
|
showsHorizontalScrollIndicator={false}
|
||||||
{result.templateThumbnail && (
|
onScroll={handleScroll}
|
||||||
<View style={styles.referencePreview}>
|
scrollEventThrottle={16}
|
||||||
<Image
|
contentContainerStyle={styles.carouselContent}
|
||||||
source={{ uri: result.templateThumbnail }}
|
>
|
||||||
style={styles.referenceImage}
|
{mediaUrls.map((url, index) => renderMediaItem(url, index))}
|
||||||
/>
|
</ScrollView>
|
||||||
</View>
|
|
||||||
)}
|
{hasMultipleMedia && (
|
||||||
</>
|
<View style={styles.pagination}>
|
||||||
)}
|
{mediaUrls.map((_, index) => (
|
||||||
</View>
|
<View
|
||||||
|
key={`dot-${index}`}
|
||||||
|
style={[
|
||||||
|
styles.paginationDot,
|
||||||
|
index === currentMediaIndex && styles.paginationDotActive,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isTextResult && (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
style={styles.detailCard}
|
style={styles.expandButton}
|
||||||
onPress={() => openFullscreen(result.type === 'VIDEO' ? 'video' : 'image', primaryPreviewUrl)}
|
onPress={() =>
|
||||||
|
openFullscreen(
|
||||||
|
getMediaType(mediaUrls[currentMediaIndex]),
|
||||||
|
mediaUrls[currentMediaIndex]
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<View style={styles.expandIcon}>
|
|
||||||
<Maximize2 size={18} color="#F5F5F7" />
|
<Maximize2 size={18} color="#F5F5F7" />
|
||||||
</View>
|
|
||||||
<View style={styles.detailMediaWrapper}>
|
|
||||||
{renderVisualMedia(primaryPreviewUrl, {
|
|
||||||
image: styles.detailMedia,
|
|
||||||
video: styles.detailMedia,
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
@@ -406,9 +505,11 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingBottom: 160,
|
paddingBottom: 160,
|
||||||
},
|
},
|
||||||
|
contentPadding: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
@@ -440,16 +541,50 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
heroCard: {
|
heroCard: {
|
||||||
marginTop: 32,
|
marginTop: 32,
|
||||||
|
marginHorizontal: 24,
|
||||||
borderRadius: 32,
|
borderRadius: 32,
|
||||||
backgroundColor: '#131317',
|
backgroundColor: '#131317',
|
||||||
padding: 24,
|
padding: 24,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
heroMedia: {
|
mediaContainer: {
|
||||||
width: '100%',
|
marginTop: 32,
|
||||||
height: HERO_HEIGHT,
|
position: 'relative',
|
||||||
borderRadius: 28,
|
backgroundColor: '#09090B',
|
||||||
|
},
|
||||||
|
carouselContent: {
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
paginationDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
paginationDotActive: {
|
||||||
|
backgroundColor: '#D9FF3F',
|
||||||
|
width: 24,
|
||||||
|
},
|
||||||
|
expandButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
textScroll: {
|
textScroll: {
|
||||||
maxHeight: HERO_HEIGHT,
|
maxHeight: HERO_HEIGHT,
|
||||||
@@ -459,52 +594,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 24,
|
lineHeight: 24,
|
||||||
},
|
},
|
||||||
referencePreview: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 24,
|
|
||||||
left: 24,
|
|
||||||
width: 74,
|
|
||||||
height: 74,
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 4,
|
|
||||||
backgroundColor: '#050506',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
},
|
|
||||||
referenceImage: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 16,
|
|
||||||
},
|
|
||||||
detailCard: {
|
|
||||||
marginTop: 28,
|
|
||||||
borderRadius: 28,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
|
||||||
backgroundColor: '#101012',
|
|
||||||
padding: 20,
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
detailMediaWrapper: {
|
|
||||||
borderRadius: 24,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
detailMedia: {
|
|
||||||
width: '100%',
|
|
||||||
height: DETAIL_HEIGHT,
|
|
||||||
},
|
|
||||||
expandIcon: {
|
|
||||||
position: 'absolute',
|
|
||||||
right: 20,
|
|
||||||
top: 20,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: 'rgba(24, 24, 28, 0.86)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
fullscreenContainer: {
|
fullscreenContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#000000',
|
backgroundColor: '#000000',
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import { BackButton } from '@/components/ui/back-button';
|
|||||||
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
|
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
|
||||||
import { getTemplateById } from '@/lib/api/templates';
|
import { getTemplateById } from '@/lib/api/templates';
|
||||||
import { recordTokenUsage, getUserBalance } from '@/lib/api/balance';
|
import { recordTokenUsage, getUserBalance } from '@/lib/api/balance';
|
||||||
import { runTemplate } from '@/lib/api/template-runs';
|
import { runTemplate, pollTemplateGeneration } from '@/lib/api/template-runs';
|
||||||
import { uploadFile } from '@/lib/api/upload';
|
import { uploadFile } from '@/lib/api/upload';
|
||||||
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
||||||
|
import { TemplateGeneration } from '@/lib/types/template-run';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
|
||||||
const FALLBACK_PREVIEW =
|
const FALLBACK_PREVIEW =
|
||||||
@@ -41,6 +42,11 @@ export default function TemplateFormScreen() {
|
|||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [pollingStatus, setPollingStatus] = useState<{
|
||||||
|
isPolling: boolean;
|
||||||
|
generationId?: string;
|
||||||
|
message?: string;
|
||||||
|
}>({ isPolling: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInitialData();
|
loadInitialData();
|
||||||
@@ -294,15 +300,33 @@ export default function TemplateFormScreen() {
|
|||||||
|
|
||||||
const generationId = runResponse.data;
|
const generationId = runResponse.data;
|
||||||
|
|
||||||
// 步骤 7: 跳转到结果页面
|
// 步骤 7: 开始轮询任务状态
|
||||||
Alert.alert('成功', '视频生成任务已创建', [
|
setPollingStatus({
|
||||||
{
|
isPolling: true,
|
||||||
text: '查看结果',
|
generationId,
|
||||||
onPress: () => {
|
message: '正在生成内容,请稍候...',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用轮询函数
|
||||||
|
pollTemplateGeneration(
|
||||||
|
generationId,
|
||||||
|
// 成功回调
|
||||||
|
(result: TemplateGeneration) => {
|
||||||
|
setPollingStatus({ isPolling: false });
|
||||||
|
|
||||||
|
// 跳转到结果页面
|
||||||
router.push(`/result?generationId=${generationId}`);
|
router.push(`/result?generationId=${generationId}`);
|
||||||
},
|
},
|
||||||
|
// 错误回调
|
||||||
|
(error: Error) => {
|
||||||
|
setPollingStatus({ isPolling: false });
|
||||||
|
Alert.alert('生成失败', error.message || '任务执行失败,请重试');
|
||||||
},
|
},
|
||||||
]);
|
// 最大尝试次数(100次 * 3秒 = 5分钟)
|
||||||
|
100,
|
||||||
|
// 轮询间隔(3秒)
|
||||||
|
3000
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submission failed:', error);
|
console.error('Submission failed:', error);
|
||||||
Alert.alert('错误', '提交失败,请稍后重试');
|
Alert.alert('错误', '提交失败,请稍后重试');
|
||||||
@@ -365,13 +389,24 @@ export default function TemplateFormScreen() {
|
|||||||
{renderFormFields()}
|
{renderFormFields()}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.generateButton, isSubmitting && styles.generateButtonDisabled]}
|
style={[
|
||||||
|
styles.generateButton,
|
||||||
|
(isSubmitting || pollingStatus.isPolling) && styles.generateButtonDisabled
|
||||||
|
]}
|
||||||
activeOpacity={0.88}
|
activeOpacity={0.88}
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || pollingStatus.isPolling}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
<ActivityIndicator size="small" color="#050505" />
|
<ActivityIndicator size="small" color="#050505" />
|
||||||
|
<Text style={styles.generateLabel}>提交中...</Text>
|
||||||
|
</>
|
||||||
|
) : pollingStatus.isPolling ? (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator size="small" color="#050505" />
|
||||||
|
<Text style={styles.generateLabel}>{pollingStatus.message || '生成中...'}</Text>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Feather name="star" size={20} color="#050505" />
|
<Feather name="star" size={20} color="#050505" />
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import { View, FlatList, ActivityIndicator, RefreshControl, StyleSheet, Image } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
FlatList,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
StyleSheet,
|
||||||
|
Image,
|
||||||
|
TouchableOpacity,
|
||||||
|
Platform
|
||||||
|
} from 'react-native';
|
||||||
import { VideoPlayer } from '@/components/video/video-player';
|
import { VideoPlayer } from '@/components/video/video-player';
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { ThemedText } from '@/components/themed-text';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
import { router } from 'expo-router';
|
||||||
import type { TemplateGeneration } from '@/lib/api/template-generations';
|
import type { TemplateGeneration } from '@/lib/api/template-generations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件 URL 后缀名判断媒体类型
|
||||||
|
*/
|
||||||
|
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||||
|
if (!url) return 'unknown';
|
||||||
|
|
||||||
|
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||||
|
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||||
|
|
||||||
|
if (imageExts.includes(extension)) return 'image';
|
||||||
|
if (videoExts.includes(extension)) return 'video';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
export function ContentGallery({
|
export function ContentGallery({
|
||||||
generations,
|
generations,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
@@ -13,6 +39,7 @@ export function ContentGallery({
|
|||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
|
ListHeaderComponent,
|
||||||
}: {
|
}: {
|
||||||
generations: TemplateGeneration[];
|
generations: TemplateGeneration[];
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
@@ -20,6 +47,7 @@ export function ContentGallery({
|
|||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
|
ListHeaderComponent?: React.ComponentType<any> | React.ReactElement | null;
|
||||||
}) {
|
}) {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||||
@@ -33,9 +61,23 @@ export function ContentGallery({
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
|
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
|
||||||
<ActivityIndicator size="small" color={palette.accent} />
|
<ActivityIndicator size="small" color={palette.accent} />
|
||||||
|
<ThemedText style={[styles.loadingMoreText, { color: palette.textSecondary }]}>
|
||||||
|
加载更多...
|
||||||
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasMore && generations.length > 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.noMoreContainer, { backgroundColor: palette.background }]}>
|
||||||
|
<ThemedText style={[styles.noMoreText, { color: palette.textSecondary }]}>
|
||||||
|
没有更多内容了
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +85,7 @@ export function ContentGallery({
|
|||||||
<FlatList
|
<FlatList
|
||||||
data={generations}
|
data={generations}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={renderFooter}
|
||||||
numColumns={2}
|
numColumns={2}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
@@ -58,11 +101,16 @@ export function ContentGallery({
|
|||||||
}
|
}
|
||||||
onEndReached={onLoadMore}
|
onEndReached={onLoadMore}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
maxToRenderPerBatch={6}
|
||||||
|
updateCellsBatchingPeriod={50}
|
||||||
|
windowSize={10}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContentItem({
|
const ContentItem = memo(function ContentItem({
|
||||||
palette,
|
palette,
|
||||||
generation,
|
generation,
|
||||||
}: {
|
}: {
|
||||||
@@ -79,17 +127,37 @@ function ContentItem({
|
|||||||
|
|
||||||
const renderMedia = () => {
|
const renderMedia = () => {
|
||||||
const mediaUrl = generation.resultUrl[0];
|
const mediaUrl = generation.resultUrl[0];
|
||||||
if (generation.type === 'IMAGE') {
|
|
||||||
|
if (!mediaUrl) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.mediaContainer, styles.placeholderContainer]}>
|
||||||
|
<ThemedText style={styles.placeholderText}>
|
||||||
|
{generation.type === 'VIDEO' ? '🎬' : generation.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = getMediaType(mediaUrl);
|
||||||
|
|
||||||
|
if (mediaType === 'video') {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
return (
|
return (
|
||||||
<View style={styles.mediaContainer}>
|
<View style={styles.mediaContainer}>
|
||||||
<Image
|
<video
|
||||||
source={{ uri: mediaUrl }}
|
src={mediaUrl}
|
||||||
style={styles.mediaImage}
|
style={{
|
||||||
resizeMode="cover"
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover' as any,
|
||||||
|
}}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else if (generation.type === 'VIDEO') {
|
} else {
|
||||||
return (
|
return (
|
||||||
<View style={styles.mediaContainer}>
|
<View style={styles.mediaContainer}>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
@@ -105,18 +173,29 @@ function ContentItem({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.mediaContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: mediaUrl }}
|
||||||
|
style={styles.mediaImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.contentItem,
|
styles.contentItem,
|
||||||
{
|
{
|
||||||
backgroundColor: palette.surface,
|
backgroundColor: palette.surface,
|
||||||
borderColor: palette.border,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
onPress={() => router.push(`/result?generationId=${generation.id}`)}
|
||||||
|
activeOpacity={0.9}
|
||||||
>
|
>
|
||||||
{renderMedia()}
|
{renderMedia()}
|
||||||
<View style={styles.contentInfo}>
|
<View style={styles.contentInfo}>
|
||||||
@@ -142,9 +221,9 @@ function ContentItem({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const darkPalette = {
|
const darkPalette = {
|
||||||
background: '#050505',
|
background: '#050505',
|
||||||
@@ -165,13 +244,16 @@ const lightPalette = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
contentContainer: {
|
||||||
|
paddingBottom: 120,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
columnWrapper: {
|
columnWrapper: {
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
contentItem: {
|
contentItem: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: 16,
|
borderRadius: 12,
|
||||||
borderWidth: 1,
|
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginHorizontal: 6,
|
marginHorizontal: 6,
|
||||||
@@ -181,6 +263,14 @@ const styles = StyleSheet.create({
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
},
|
},
|
||||||
|
placeholderContainer: {
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
fontSize: 48,
|
||||||
|
},
|
||||||
mediaImage: {
|
mediaImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -224,5 +314,18 @@ const styles = StyleSheet.create({
|
|||||||
loadingMoreContainer: {
|
loadingMoreContainer: {
|
||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
loadingMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
noMoreContainer: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
noMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { TouchableOpacity, View } from 'react-native';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { ThemedText } from '@/components/themed-text';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
@@ -62,7 +62,7 @@ function BillingBadge({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={option.key}
|
key={option.key}
|
||||||
onPress={() => onChangeBilling(option.key)}
|
onPress={() => router.push('/exchange')}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
style={[
|
style={[
|
||||||
styles.billingOption,
|
styles.billingOption,
|
||||||
@@ -85,7 +85,9 @@ function BillingBadge({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
<View
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/exchange')}
|
||||||
|
activeOpacity={0.85}
|
||||||
style={[
|
style={[
|
||||||
styles.lightningShell,
|
styles.lightningShell,
|
||||||
{
|
{
|
||||||
@@ -96,7 +98,7 @@ function BillingBadge({
|
|||||||
>
|
>
|
||||||
<MaterialIcons name="flash-on" size={16} color={palette.accent} />
|
<MaterialIcons name="flash-on" size={16} color={palette.accent} />
|
||||||
<ThemedText style={[styles.lightningValue, { color: palette.textPrimary }]}>{credits}</ThemedText>
|
<ThemedText style={[styles.lightningValue, { color: palette.textPrimary }]}>{credits}</ThemedText>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { View, useWindowDimensions, type ImageSourcePropType } from 'react-native';
|
import { View, useWindowDimensions, type ImageSourcePropType, ActivityIndicator } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
@@ -37,6 +37,7 @@ export function ProfileScreen() {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
isTabSwitching,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleLoadMore,
|
handleLoadMore,
|
||||||
} = useProfileData(activeTab);
|
} = useProfileData(activeTab);
|
||||||
@@ -56,6 +57,47 @@ export function ProfileScreen() {
|
|||||||
setPresentedAvatar(avatarSource);
|
setPresentedAvatar(avatarSource);
|
||||||
}, [avatarSource]);
|
}, [avatarSource]);
|
||||||
|
|
||||||
|
const headerComponent = useMemo(
|
||||||
|
() => (
|
||||||
|
<View style={{ paddingHorizontal: horizontalPadding, paddingBottom: 16 }}>
|
||||||
|
<ProfileHeader
|
||||||
|
billingMode={billingMode}
|
||||||
|
onChangeBilling={setBillingMode}
|
||||||
|
credits={creditBalance}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProfileIdentity
|
||||||
|
displayName={presentedDisplayName}
|
||||||
|
avatarSource={presentedAvatar}
|
||||||
|
avatarSize={avatarSize}
|
||||||
|
stats={stats}
|
||||||
|
onEdit={() => setIsEditingIdentity(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ContentTabs
|
||||||
|
activeTab={activeTab}
|
||||||
|
onChangeTab={setActiveTab}
|
||||||
|
isLoading={isTabSwitching}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
horizontalPadding,
|
||||||
|
billingMode,
|
||||||
|
creditBalance,
|
||||||
|
presentedDisplayName,
|
||||||
|
presentedAvatar,
|
||||||
|
avatarSize,
|
||||||
|
stats,
|
||||||
|
activeTab,
|
||||||
|
isTabSwitching,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||||
@@ -78,38 +120,17 @@ export function ProfileScreen() {
|
|||||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||||
|
|
||||||
<View style={{ paddingHorizontal: horizontalPadding }}>
|
|
||||||
<ProfileHeader
|
|
||||||
billingMode={billingMode}
|
|
||||||
onChangeBilling={setBillingMode}
|
|
||||||
credits={creditBalance}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProfileIdentity
|
|
||||||
displayName={presentedDisplayName}
|
|
||||||
avatarSource={presentedAvatar}
|
|
||||||
avatarSize={avatarSize}
|
|
||||||
stats={stats}
|
|
||||||
onEdit={() => setIsEditingIdentity(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<ContentTabs
|
|
||||||
activeTab={activeTab}
|
|
||||||
onChangeTab={setActiveTab}
|
|
||||||
isLoading={isLoading && generations.length > 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading && generations.length === 0 ? (
|
{isLoading && generations.length === 0 ? (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{headerComponent}
|
||||||
<ContentSkeleton />
|
<ContentSkeleton />
|
||||||
) : generations.length === 0 ? (
|
</View>
|
||||||
|
) : generations.length === 0 && !isTabSwitching ? (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{headerComponent}
|
||||||
<ProfileEmptyState activeTab={activeTab} />
|
<ProfileEmptyState activeTab={activeTab} />
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.galleryContainer, { paddingHorizontal: horizontalPadding / 2 }]}>
|
|
||||||
<ContentGallery
|
<ContentGallery
|
||||||
generations={generations}
|
generations={generations}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
@@ -117,9 +138,10 @@ export function ProfileScreen() {
|
|||||||
isLoadingMore={isLoadingMore}
|
isLoadingMore={isLoadingMore}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
|
ListHeaderComponent={headerComponent}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ProfileEditModal
|
<ProfileEditModal
|
||||||
visible={isEditingIdentity}
|
visible={isEditingIdentity}
|
||||||
avatarSource={presentedAvatar}
|
avatarSource={presentedAvatar}
|
||||||
@@ -146,9 +168,6 @@ const styles = StyleSheet.create({
|
|||||||
screen: {
|
screen: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
galleryContainer: {
|
|
||||||
paddingTop: 8,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ProfileScreen;
|
export default ProfileScreen;
|
||||||
|
|||||||
@@ -12,21 +12,22 @@ export function useProfileData(activeTab: TabKey) {
|
|||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isTabSwitching, setIsTabSwitching] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGenerations([]);
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
loadGenerations(1, true);
|
setIsTabSwitching(true);
|
||||||
|
loadGenerations(1, false, true);
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const loadGenerations = async (page = 1, isRefresh = false) => {
|
const loadGenerations = async (page = 1, isRefresh = false, isTabSwitch = false) => {
|
||||||
try {
|
try {
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
} else if (page === 1) {
|
} else if (page === 1 && !isTabSwitch) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
} else {
|
} else if (page > 1) {
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -57,6 +58,7 @@ export function useProfileData(activeTab: TabKey) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
|
setIsTabSwitching(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ export function useProfileData(activeTab: TabKey) {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
isTabSwitching,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleLoadMore,
|
handleLoadMore,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -210,5 +210,5 @@ export async function recordTokenUsage(
|
|||||||
* 跳转到充值页面
|
* 跳转到充值页面
|
||||||
*/
|
*/
|
||||||
export function redirectToPricePage() {
|
export function redirectToPricePage() {
|
||||||
router.push('/exchange' as any);
|
router.push('/exchange');
|
||||||
}
|
}
|
||||||
|
|||||||
5
lib/api/todo.md
Normal file
5
lib/api/todo.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
页面:/profile
|
||||||
|
|
||||||
|
点击顶部 月付 应该跳转到 app\recharge.tsx
|
||||||
|
|
||||||
|
点击顶部的 余额 应该跳转到 app\exchange.tsx
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiClient } from './client';
|
import { storage } from '../storage';
|
||||||
|
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -11,6 +11,10 @@ export interface UploadResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getAuthToken(): Promise<string> {
|
||||||
|
return (await storage.getItem(`bestaibest.better-auth.session_token`)) || '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件到服务器
|
* 上传文件到服务器
|
||||||
* @param uri 本地文件 URI(可以是 blob: 或 file:// 格式)
|
* @param uri 本地文件 URI(可以是 blob: 或 file:// 格式)
|
||||||
@@ -31,43 +35,100 @@ export async function uploadFile(uri: string, type: 'image' | 'video'): Promise<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先获取 blob 以确定实际的 MIME 类型
|
||||||
|
const response = await fetch(uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// 从 blob 获取实际的 MIME 类型
|
||||||
|
let mimeType = blob.type;
|
||||||
|
|
||||||
|
// 如果 blob 没有 type,则根据参数推断
|
||||||
|
if (!mimeType || mimeType === 'application/octet-stream') {
|
||||||
|
mimeType = type === 'image' ? 'image/jpeg' : 'video/mp4';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 MIME 类型确定文件扩展名
|
||||||
|
let extension: string;
|
||||||
|
if (mimeType.startsWith('image/')) {
|
||||||
|
const imageExtensions: Record<string, string> = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
};
|
||||||
|
extension = imageExtensions[mimeType] || 'jpg';
|
||||||
|
} else {
|
||||||
|
const videoExtensions: Record<string, string> = {
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'video/avi': 'avi',
|
||||||
|
'video/quicktime': 'mov',
|
||||||
|
};
|
||||||
|
extension = videoExtensions[mimeType] || 'mp4';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
const filename = `${type}_${Date.now()}.${extension}`;
|
||||||
|
|
||||||
|
// 创建 File 对象
|
||||||
|
const file = new File([blob], filename, { type: mimeType });
|
||||||
|
|
||||||
// 创建 FormData
|
// 创建 FormData
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
// 从 URI 中提取文件信息
|
// 获取认证 token
|
||||||
const filename = uri.split('/').pop() || `${type}_${Date.now()}`;
|
const token = await getAuthToken();
|
||||||
const match = /\.(\w+)$/.exec(filename);
|
|
||||||
const fileType = match ? match[1] : (type === 'image' ? 'jpg' : 'mp4');
|
|
||||||
|
|
||||||
// 添加文件到 FormData
|
// 上传文件(不要手动设置 Content-Type,让浏览器自动添加 boundary)
|
||||||
formData.append('file', {
|
const uploadResponse = await fetch(`https://api.mixvideo.bowong.cc/api/file/upload/s3`, {
|
||||||
uri,
|
|
||||||
type: type === 'image' ? `image/${fileType}` : `video/${fileType}`,
|
|
||||||
name: filename,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
formData.append('type', type);
|
|
||||||
|
|
||||||
// 上传文件
|
|
||||||
const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL || 'https://api.mixvideo.bowong.cc'}/api/upload`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: token ? {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
} : {},
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!uploadResponse.ok) {
|
||||||
throw new Error(`Upload failed: ${response.statusText}`);
|
const errorData = await uploadResponse.json();
|
||||||
|
const errorMessage = errorData?.error?.message || errorData?.message || uploadResponse.statusText;
|
||||||
|
throw new Error(`Upload failed: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await uploadResponse.json();
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.message || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 支持多种返回格式:
|
||||||
|
// 1. { status: true, msg: "...", data: "url" } - 实际 S3 上传格式
|
||||||
|
// 2. { success: true, data: { url, ... } }
|
||||||
|
// 3. { data: { status: true, data: "url" } }
|
||||||
|
if (result.status === true && result.data) {
|
||||||
|
// 格式 1(实际 S3 格式)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
url: result.data,
|
||||||
|
filename: filename,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: mimeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (result.data?.status && result.data?.data) {
|
||||||
|
// 格式 3
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
url: result.data.data,
|
||||||
|
filename: filename,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: mimeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (result.success && result.data) {
|
||||||
|
// 格式 2
|
||||||
return result;
|
return result;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error?.message || result.msg || result.message || 'Upload failed');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload file:', error);
|
console.error('Failed to upload file:', error);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user