fix: 修复布局问题

This commit is contained in:
imeepos
2025-11-12 10:02:55 +08:00
parent 26cd0139bf
commit a7d922b535
12 changed files with 876 additions and 699 deletions

View File

@@ -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',
},
});

View File

@@ -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>

View File

@@ -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,
},
});

View File

@@ -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',

View File

@@ -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" />

View File

@@ -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,
},
});

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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
View File

@@ -0,0 +1,5 @@
页面:/profile
点击顶部 月付 应该跳转到 app\recharge.tsx
点击顶部的 余额 应该跳转到 app\exchange.tsx

View File

@@ -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 {