fix: 修复布局问题
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user