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