fix: 修复布局问题
This commit is contained in:
@@ -1,14 +1,41 @@
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActivityIndicator, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
RefreshControl,
|
||||
Platform,
|
||||
TouchableOpacity
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
interface GroupedData {
|
||||
[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 groups: GroupedData = {};
|
||||
|
||||
@@ -115,30 +142,127 @@ const getStatusText = (status: string): string => {
|
||||
export default function HistoryScreen() {
|
||||
const [groupedData, setGroupedData] = useState<GroupedData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
|
||||
|
||||
const fetchData = async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else if (pageNum === 1) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
const response = await getTemplateGenerations({ page: pageNum, limit: 20 });
|
||||
|
||||
if (response.success && response.data) {
|
||||
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);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getTemplateGenerations({ limit: 100 });
|
||||
|
||||
if (response.success && response.data) {
|
||||
const grouped = groupByDate(response.data.generations);
|
||||
setGroupedData(grouped);
|
||||
} else {
|
||||
setError('获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
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) {
|
||||
return (
|
||||
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
|
||||
@@ -173,6 +297,16 @@ export default function HistoryScreen() {
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor="#FFFFFF"
|
||||
colors={['#FFFFFF']}
|
||||
/>
|
||||
}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
<Text style={styles.heading}>Content Generation</Text>
|
||||
|
||||
@@ -190,19 +324,13 @@ export default function HistoryScreen() {
|
||||
<View style={styles.gallery}>
|
||||
<View style={styles.leadingLane}>
|
||||
{leftColumn.map(item => (
|
||||
<View key={item.id} style={styles.frame}>
|
||||
{item.resultUrl && item.resultUrl.length > 0 ? (
|
||||
<Image
|
||||
source={{ uri: item.resultUrl[0] }}
|
||||
style={[styles.image, { height: getImageHeight(item.type) }]}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.placeholderImage]}>
|
||||
<Text style={styles.placeholderText}>
|
||||
{item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={styles.frame}
|
||||
onPress={() => router.push(`/result?generationId=${item.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{renderMediaItem(item)}
|
||||
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.statusBadge}>
|
||||
@@ -211,25 +339,19 @@ export default function HistoryScreen() {
|
||||
</View>
|
||||
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.trailingLane}>
|
||||
{rightColumn.map(item => (
|
||||
<View key={item.id} style={styles.frame}>
|
||||
{item.resultUrl && item.resultUrl.length > 0 ? (
|
||||
<Image
|
||||
source={{ uri: item.resultUrl[0] }}
|
||||
style={[styles.image, { height: getImageHeight(item.type) }]}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.placeholderImage]}>
|
||||
<Text style={styles.placeholderText}>
|
||||
{item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={styles.frame}
|
||||
onPress={() => router.push(`/result?generationId=${item.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{renderMediaItem(item)}
|
||||
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.statusBadge}>
|
||||
@@ -238,7 +360,7 @@ export default function HistoryScreen() {
|
||||
</View>
|
||||
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</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>
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
@@ -292,13 +427,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
frame: {
|
||||
width: '100%',
|
||||
borderRadius: 28,
|
||||
borderRadius: 16,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1A1A1A',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
borderRadius: 28,
|
||||
},
|
||||
placeholderImage: {
|
||||
backgroundColor: '#2A2A2A',
|
||||
@@ -308,15 +443,28 @@ const styles = StyleSheet.create({
|
||||
placeholderText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
videoPlaceholder: {
|
||||
backgroundColor: '#1A1A1A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
videoIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 8,
|
||||
},
|
||||
videoText: {
|
||||
fontSize: 14,
|
||||
color: '#9E9E9E',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderBottomLeftRadius: 28,
|
||||
borderBottomRightRadius: 28,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderBottomLeftRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
@@ -374,4 +522,23 @@ const styles = StyleSheet.create({
|
||||
dateGroup: {
|
||||
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.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<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>
|
||||
</AuthProvider>
|
||||
</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,
|
||||
},
|
||||
});
|
||||
509
app/result.tsx
509
app/result.tsx
@@ -1,8 +1,10 @@
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Platform } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { getTemplateGeneration } from '@/lib/api/template-runs';
|
||||
|
||||
// ResizeMode 兼容映射 - 移除 'stretch',expo-video 不支持
|
||||
const ResizeMode = {
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Image,
|
||||
@@ -34,11 +37,12 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
const HERO_HEIGHT = screenWidth * 0.58;
|
||||
const DETAIL_HEIGHT = screenWidth * 0.95;
|
||||
const VIDEO_HEIGHT = screenHeight * 0.6;
|
||||
|
||||
interface ResultWithTemplate {
|
||||
id: string;
|
||||
@@ -51,86 +55,63 @@ interface ResultWithTemplate {
|
||||
creditsTransactionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
templateName?: string;
|
||||
templateThumbnail?: string;
|
||||
templateDescription?: string;
|
||||
template?: {
|
||||
id: 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() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { generationId } = useLocalSearchParams<{ generationId: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [result, setResult] = useState<ResultWithTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fullscreenVisible, setFullscreenVisible] = useState(false);
|
||||
const [fullscreenContent, setFullscreenContent] = useState<{
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadResult();
|
||||
}, [id]);
|
||||
if (generationId) {
|
||||
loadResult();
|
||||
}
|
||||
}, [generationId]);
|
||||
|
||||
const loadResult = async () => {
|
||||
if (!generationId) return;
|
||||
|
||||
try {
|
||||
const typeParam = typeof id === 'string' ? id.split('_')[1] : 'text';
|
||||
const resultId = typeof id === 'string' ? id : 'res1_text';
|
||||
setLoading(true);
|
||||
const response = await getTemplateGeneration(generationId);
|
||||
|
||||
const getResultByType = () => {
|
||||
switch (typeParam) {
|
||||
case 'image':
|
||||
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());
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data);
|
||||
} else {
|
||||
Alert.alert('错误', '无法加载结果详情');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '无法加载结果详情');
|
||||
console.error('Failed to load result:', error);
|
||||
Alert.alert('错误', '加载结果失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,22 +120,112 @@ export default function ResultPage() {
|
||||
};
|
||||
|
||||
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 {
|
||||
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();
|
||||
console.log('Permission status:', status);
|
||||
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
|
||||
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) {
|
||||
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('保存失败', '无法保存文件,请检查网络连接');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
|
||||
console.error('Save to gallery error:', error);
|
||||
Alert.alert('保存失败', '无法保存文件,请稍后重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
console.log('handleSaveToGallery completed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,7 +246,14 @@ export default function ResultPage() {
|
||||
};
|
||||
|
||||
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') {
|
||||
handleCopyText();
|
||||
@@ -185,57 +263,69 @@ export default function ResultPage() {
|
||||
handleSaveToGallery();
|
||||
};
|
||||
|
||||
const renderVisualMedia = (
|
||||
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) {
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#D1FF00" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
</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 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 (
|
||||
<ThemedView style={styles.screen}>
|
||||
@@ -245,62 +335,71 @@ export default function ResultPage() {
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.7}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={22} color="#F6F6F8" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.contentPadding}>
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.7}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={22} color="#F6F6F8" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
{result.template?.title || '生成结果'}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{result.template?.titleEn || ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
INSEAR YOUR PRODUCT INTO AN ASMR VIDEO
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.
|
||||
</Text>
|
||||
|
||||
<View style={styles.heroCard}>
|
||||
{isTextResult ? (
|
||||
{isTextResult ? (
|
||||
<View style={styles.heroCard}>
|
||||
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.generatedText}>{primaryPreviewUrl}</Text>
|
||||
<Text style={styles.generatedText}>{mediaUrls[0]}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.mediaContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
>
|
||||
{mediaUrls.map((url, index) => renderMediaItem(url, index))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{renderVisualMedia(primaryPreviewUrl, {
|
||||
image: styles.heroMedia,
|
||||
video: styles.heroMedia,
|
||||
})}
|
||||
{result.templateThumbnail && (
|
||||
<View style={styles.referencePreview}>
|
||||
<Image
|
||||
source={{ uri: result.templateThumbnail }}
|
||||
style={styles.referenceImage}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!isTextResult && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.detailCard}
|
||||
onPress={() => openFullscreen(result.type === 'VIDEO' ? 'video' : 'image', primaryPreviewUrl)}
|
||||
>
|
||||
<View style={styles.expandIcon}>
|
||||
{hasMultipleMedia && (
|
||||
<View style={styles.pagination}>
|
||||
{mediaUrls.map((_, index) => (
|
||||
<View
|
||||
key={`dot-${index}`}
|
||||
style={[
|
||||
styles.paginationDot,
|
||||
index === currentMediaIndex && styles.paginationDotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.expandButton}
|
||||
onPress={() =>
|
||||
openFullscreen(
|
||||
getMediaType(mediaUrls[currentMediaIndex]),
|
||||
mediaUrls[currentMediaIndex]
|
||||
)
|
||||
}
|
||||
>
|
||||
<Maximize2 size={18} color="#F5F5F7" />
|
||||
</View>
|
||||
<View style={styles.detailMediaWrapper}>
|
||||
{renderVisualMedia(primaryPreviewUrl, {
|
||||
image: styles.detailMedia,
|
||||
video: styles.detailMedia,
|
||||
})}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -406,9 +505,11 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 160,
|
||||
},
|
||||
contentPadding: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
@@ -440,16 +541,50 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
heroCard: {
|
||||
marginTop: 32,
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#131317',
|
||||
padding: 24,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroMedia: {
|
||||
width: '100%',
|
||||
height: HERO_HEIGHT,
|
||||
borderRadius: 28,
|
||||
mediaContainer: {
|
||||
marginTop: 32,
|
||||
position: 'relative',
|
||||
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: {
|
||||
maxHeight: HERO_HEIGHT,
|
||||
@@ -459,52 +594,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
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: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000000',
|
||||
|
||||
@@ -21,9 +21,10 @@ import { BackButton } from '@/components/ui/back-button';
|
||||
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
|
||||
import { getTemplateById } from '@/lib/api/templates';
|
||||
import { recordTokenUsage, getUserBalance } from '@/lib/api/balance';
|
||||
import { runTemplate } from '@/lib/api/template-runs';
|
||||
import { runTemplate, pollTemplateGeneration } from '@/lib/api/template-runs';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
||||
import { TemplateGeneration } from '@/lib/types/template-run';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
const FALLBACK_PREVIEW =
|
||||
@@ -41,6 +42,11 @@ export default function TemplateFormScreen() {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [pollingStatus, setPollingStatus] = useState<{
|
||||
isPolling: boolean;
|
||||
generationId?: string;
|
||||
message?: string;
|
||||
}>({ isPolling: false });
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
@@ -294,15 +300,33 @@ export default function TemplateFormScreen() {
|
||||
|
||||
const generationId = runResponse.data;
|
||||
|
||||
// 步骤 7: 跳转到结果页面
|
||||
Alert.alert('成功', '视频生成任务已创建', [
|
||||
{
|
||||
text: '查看结果',
|
||||
onPress: () => {
|
||||
router.push(`/result?generationId=${generationId}`);
|
||||
},
|
||||
// 步骤 7: 开始轮询任务状态
|
||||
setPollingStatus({
|
||||
isPolling: true,
|
||||
generationId,
|
||||
message: '正在生成内容,请稍候...',
|
||||
});
|
||||
|
||||
// 使用轮询函数
|
||||
pollTemplateGeneration(
|
||||
generationId,
|
||||
// 成功回调
|
||||
(result: TemplateGeneration) => {
|
||||
setPollingStatus({ isPolling: false });
|
||||
|
||||
// 跳转到结果页面
|
||||
router.push(`/result?generationId=${generationId}`);
|
||||
},
|
||||
]);
|
||||
// 错误回调
|
||||
(error: Error) => {
|
||||
setPollingStatus({ isPolling: false });
|
||||
Alert.alert('生成失败', error.message || '任务执行失败,请重试');
|
||||
},
|
||||
// 最大尝试次数(100次 * 3秒 = 5分钟)
|
||||
100,
|
||||
// 轮询间隔(3秒)
|
||||
3000
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Submission failed:', error);
|
||||
Alert.alert('错误', '提交失败,请稍后重试');
|
||||
@@ -365,13 +389,24 @@ export default function TemplateFormScreen() {
|
||||
{renderFormFields()}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.generateButton, isSubmitting && styles.generateButtonDisabled]}
|
||||
style={[
|
||||
styles.generateButton,
|
||||
(isSubmitting || pollingStatus.isPolling) && styles.generateButtonDisabled
|
||||
]}
|
||||
activeOpacity={0.88}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || pollingStatus.isPolling}
|
||||
>
|
||||
{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" />
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import React from 'react';
|
||||
import { View, FlatList, ActivityIndicator, RefreshControl, StyleSheet, Image } from 'react-native';
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { router } from 'expo-router';
|
||||
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({
|
||||
generations,
|
||||
isRefreshing,
|
||||
@@ -13,6 +39,7 @@ export function ContentGallery({
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
ListHeaderComponent,
|
||||
}: {
|
||||
generations: TemplateGeneration[];
|
||||
isRefreshing: boolean;
|
||||
@@ -20,6 +47,7 @@ export function ContentGallery({
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
ListHeaderComponent?: React.ComponentType<any> | React.ReactElement | null;
|
||||
}) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
@@ -33,9 +61,23 @@ export function ContentGallery({
|
||||
return (
|
||||
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
|
||||
<ActivityIndicator size="small" color={palette.accent} />
|
||||
<ThemedText style={[styles.loadingMoreText, { color: palette.textSecondary }]}>
|
||||
加载更多...
|
||||
</ThemedText>
|
||||
</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;
|
||||
};
|
||||
|
||||
@@ -43,6 +85,7 @@ export function ContentGallery({
|
||||
<FlatList
|
||||
data={generations}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={renderFooter}
|
||||
numColumns={2}
|
||||
keyExtractor={(item) => item.id}
|
||||
@@ -58,11 +101,16 @@ export function ContentGallery({
|
||||
}
|
||||
onEndReached={onLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={6}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={10}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentItem({
|
||||
const ContentItem = memo(function ContentItem({
|
||||
palette,
|
||||
generation,
|
||||
}: {
|
||||
@@ -79,44 +127,75 @@ function ContentItem({
|
||||
|
||||
const renderMedia = () => {
|
||||
const mediaUrl = generation.resultUrl[0];
|
||||
if (generation.type === 'IMAGE') {
|
||||
|
||||
if (!mediaUrl) {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<Image
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (generation.type === 'VIDEO') {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<VideoPlayer
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaVideo}
|
||||
shouldPlay={false}
|
||||
isLooping={true}
|
||||
isMuted={true}
|
||||
useNativeControls={false}
|
||||
showPoster={true}
|
||||
maxHeight={300}
|
||||
/>
|
||||
<View style={[styles.mediaContainer, styles.placeholderContainer]}>
|
||||
<ThemedText style={styles.placeholderText}>
|
||||
{generation.type === 'VIDEO' ? '🎬' : generation.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
const mediaType = getMediaType(mediaUrl);
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<video
|
||||
src={mediaUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover' as any,
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<VideoPlayer
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaVideo}
|
||||
shouldPlay={false}
|
||||
isLooping={true}
|
||||
isMuted={true}
|
||||
useNativeControls={false}
|
||||
showPoster={true}
|
||||
maxHeight={300}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<Image
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.contentItem,
|
||||
{
|
||||
backgroundColor: palette.surface,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/result?generationId=${generation.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{renderMedia()}
|
||||
<View style={styles.contentInfo}>
|
||||
@@ -142,9 +221,9 @@ function ContentItem({
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const darkPalette = {
|
||||
background: '#050505',
|
||||
@@ -165,13 +244,16 @@ const lightPalette = {
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingBottom: 120,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contentItem: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 6,
|
||||
@@ -181,6 +263,14 @@ const styles = StyleSheet.create({
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
placeholderContainer: {
|
||||
backgroundColor: '#1A1A1A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
mediaImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -224,5 +314,18 @@ const styles = StyleSheet.create({
|
||||
loadingMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
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 React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
@@ -62,7 +62,7 @@ function BillingBadge({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.key}
|
||||
onPress={() => onChangeBilling(option.key)}
|
||||
onPress={() => router.push('/exchange')}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.billingOption,
|
||||
@@ -85,7 +85,9 @@ function BillingBadge({
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/exchange')}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.lightningShell,
|
||||
{
|
||||
@@ -96,7 +98,7 @@ function BillingBadge({
|
||||
>
|
||||
<MaterialIcons name="flash-on" size={16} color={palette.accent} />
|
||||
<ThemedText style={[styles.lightningValue, { color: palette.textPrimary }]}>{credits}</ThemedText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, useWindowDimensions, type ImageSourcePropType } from 'react-native';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { View, useWindowDimensions, type ImageSourcePropType, ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
@@ -37,6 +37,7 @@ export function ProfileScreen() {
|
||||
isRefreshing,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
isTabSwitching,
|
||||
handleRefresh,
|
||||
handleLoadMore,
|
||||
} = useProfileData(activeTab);
|
||||
@@ -56,6 +57,47 @@ export function ProfileScreen() {
|
||||
setPresentedAvatar(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) {
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
@@ -78,48 +120,28 @@ export function ProfileScreen() {
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
{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 ? (
|
||||
<ContentSkeleton />
|
||||
) : generations.length === 0 ? (
|
||||
<ProfileEmptyState activeTab={activeTab} />
|
||||
) : (
|
||||
<View style={[styles.galleryContainer, { paddingHorizontal: horizontalPadding / 2 }]}>
|
||||
<ContentGallery
|
||||
generations={generations}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
{headerComponent}
|
||||
<ContentSkeleton />
|
||||
</View>
|
||||
) : generations.length === 0 && !isTabSwitching ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
{headerComponent}
|
||||
<ProfileEmptyState activeTab={activeTab} />
|
||||
</View>
|
||||
) : (
|
||||
<ContentGallery
|
||||
generations={generations}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
ListHeaderComponent={headerComponent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProfileEditModal
|
||||
visible={isEditingIdentity}
|
||||
avatarSource={presentedAvatar}
|
||||
@@ -146,9 +168,6 @@ const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
galleryContainer: {
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default ProfileScreen;
|
||||
|
||||
@@ -12,21 +12,22 @@ export function useProfileData(activeTab: TabKey) {
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isTabSwitching, setIsTabSwitching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setGenerations([]);
|
||||
setCurrentPage(1);
|
||||
setHasMore(true);
|
||||
loadGenerations(1, true);
|
||||
setIsTabSwitching(true);
|
||||
loadGenerations(1, false, true);
|
||||
}, [activeTab]);
|
||||
|
||||
const loadGenerations = async (page = 1, isRefresh = false) => {
|
||||
const loadGenerations = async (page = 1, isRefresh = false, isTabSwitch = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setIsRefreshing(true);
|
||||
} else if (page === 1) {
|
||||
} else if (page === 1 && !isTabSwitch) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
} else if (page > 1) {
|
||||
setIsLoadingMore(true);
|
||||
}
|
||||
setError(null);
|
||||
@@ -57,6 +58,7 @@ export function useProfileData(activeTab: TabKey) {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsLoadingMore(false);
|
||||
setIsTabSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,6 +79,7 @@ export function useProfileData(activeTab: TabKey) {
|
||||
isRefreshing,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
isTabSwitching,
|
||||
handleRefresh,
|
||||
handleLoadMore,
|
||||
};
|
||||
|
||||
@@ -210,5 +210,5 @@ export async function recordTokenUsage(
|
||||
* 跳转到充值页面
|
||||
*/
|
||||
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 {
|
||||
success: boolean;
|
||||
@@ -11,6 +11,10 @@ export interface UploadResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function getAuthToken(): Promise<string> {
|
||||
return (await storage.getItem(`bestaibest.better-auth.session_token`)) || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到服务器
|
||||
* @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
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// 从 URI 中提取文件信息
|
||||
const filename = uri.split('/').pop() || `${type}_${Date.now()}`;
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const fileType = match ? match[1] : (type === 'image' ? 'jpg' : 'mp4');
|
||||
// 获取认证 token
|
||||
const token = await getAuthToken();
|
||||
|
||||
// 添加文件到 FormData
|
||||
formData.append('file', {
|
||||
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`, {
|
||||
// 上传文件(不要手动设置 Content-Type,让浏览器自动添加 boundary)
|
||||
const uploadResponse = await fetch(`https://api.mixvideo.bowong.cc/api/file/upload/s3`, {
|
||||
method: 'POST',
|
||||
headers: token ? {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
} : {},
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
if (!uploadResponse.ok) {
|
||||
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;
|
||||
} else {
|
||||
throw new Error(result.error?.message || result.msg || result.message || 'Upload failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user