This commit is contained in:
imeepos
2025-11-12 13:19:06 +08:00
parent a7d922b535
commit 3ebcc24e9b
14 changed files with 483 additions and 265 deletions

View File

@@ -1,6 +1,6 @@
import { ThemedView } from '@/components/themed-view';
import { PageLayout } from '@/components/bestai/layout';
import { StatusBar } from 'expo-status-bar';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
ActivityIndicator,
Image,
@@ -12,10 +12,19 @@ import {
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';
const LAYOUT_CONFIG = {
VIDEO_HEIGHT: 280,
IMAGE_HEIGHT: 240,
DEFAULT_HEIGHT: 200,
COLUMN_GAP: 16,
PAGE_SIZE: 20,
LOAD_MORE_THRESHOLD: 200,
SCROLL_THROTTLE: 800,
} as const;
interface GroupedData {
[date: string]: TemplateGeneration[];
}
@@ -86,14 +95,14 @@ const distributeToColumns = (items: TemplateGeneration[]) => {
let rightHeight = 0;
items.forEach(item => {
const height = item.type === 'VIDEO' ? 280 : 240;
const height = item.type === 'VIDEO' ? LAYOUT_CONFIG.VIDEO_HEIGHT : LAYOUT_CONFIG.IMAGE_HEIGHT;
if (leftHeight <= rightHeight) {
leftColumn.push(item);
leftHeight += height + 16;
leftHeight += height + LAYOUT_CONFIG.COLUMN_GAP;
} else {
rightColumn.push(item);
rightHeight += height + 16;
rightHeight += height + LAYOUT_CONFIG.COLUMN_GAP;
}
});
@@ -103,11 +112,11 @@ const distributeToColumns = (items: TemplateGeneration[]) => {
const getImageHeight = (type: string): number => {
switch (type) {
case 'VIDEO':
return 280;
return LAYOUT_CONFIG.VIDEO_HEIGHT;
case 'IMAGE':
return 240;
return LAYOUT_CONFIG.IMAGE_HEIGHT;
default:
return 200;
return LAYOUT_CONFIG.DEFAULT_HEIGHT;
}
};
@@ -139,8 +148,88 @@ const getStatusText = (status: string): string => {
}
};
const MediaItem = React.memo(({ item }: { 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"
/>
);
});
MediaItem.displayName = 'MediaItem';
const MediaOverlay = React.memo(({ item }: { item: TemplateGeneration }) => (
<View style={styles.overlay}>
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.status) }]} />
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
</View>
));
MediaOverlay.displayName = 'MediaOverlay';
const ColumnRenderer = React.memo(({ items }: { items: TemplateGeneration[] }) => (
<>
{items.map(item => (
<TouchableOpacity
key={item.id}
style={styles.frame}
onPress={() => router.push(`/result?generationId=${item.id}`)}
activeOpacity={0.9}
>
<MediaItem item={item} />
<MediaOverlay item={item} />
</TouchableOpacity>
))}
</>
));
ColumnRenderer.displayName = 'ColumnRenderer';
export default function HistoryScreen() {
const [groupedData, setGroupedData] = useState<GroupedData>({});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
@@ -149,7 +238,9 @@ export default function HistoryScreen() {
const [hasMore, setHasMore] = useState(true);
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
const fetchData = async (pageNum: number = 1, isRefresh: boolean = false) => {
const groupedData = useMemo(() => groupByDate(allGenerations), [allGenerations]);
const fetchData = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
try {
if (isRefresh) {
setRefreshing(true);
@@ -159,7 +250,7 @@ export default function HistoryScreen() {
setLoadingMore(true);
}
const response = await getTemplateGenerations({ page: pageNum, limit: 20 });
const response = await getTemplateGenerations({ page: pageNum, limit: LAYOUT_CONFIG.PAGE_SIZE });
if (response.success && response.data) {
const newGenerations = response.data.generations;
@@ -171,30 +262,32 @@ export default function HistoryScreen() {
setAllGenerations(prev => [...prev, ...newGenerations]);
}
setHasMore(newGenerations.length === 20);
const grouped = groupByDate(isRefresh || pageNum === 1 ? newGenerations : [...allGenerations, ...newGenerations]);
setGroupedData(grouped);
setHasMore(newGenerations.length === LAYOUT_CONFIG.PAGE_SIZE);
setError(null);
} else {
setError('获取数据失败');
}
} catch (err) {
console.error('Failed to fetch data:', err);
setError(err instanceof Error ? err.message : '获取数据失败');
const errorMessage = err instanceof Error ? err.message : '获取数据失败';
// 401 错误由 AuthProvider 处理,不显示错误页面
if (!errorMessage.includes('401')) {
setError(errorMessage);
}
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
};
}, []);
useEffect(() => {
fetchData(1);
}, []);
}, [fetchData]);
const onRefresh = useCallback(() => {
fetchData(1, true);
}, []);
}, [fetchData]);
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
@@ -202,113 +295,58 @@ export default function HistoryScreen() {
setPage(nextPage);
fetchData(nextPage, false);
}
}, [loadingMore, hasMore, page, loading]);
}, [loadingMore, hasMore, page, loading, fetchData]);
const handleScroll = (event: any) => {
const handleScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - LAYOUT_CONFIG.LOAD_MORE_THRESHOLD;
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"
/>
);
};
}, [loadingMore, hasMore, loadMore]);
if (loading) {
return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<PageLayout backgroundColor="#050505">
<StatusBar style="light" />
<SafeAreaView style={styles.safeArea}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" />
<Text style={styles.loadingText}>...</Text>
</View>
</SafeAreaView>
</ThemedView>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" />
<Text style={styles.loadingText}>...</Text>
</View>
</PageLayout>
);
}
if (error) {
return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<PageLayout backgroundColor="#050505">
<StatusBar style="light" />
<SafeAreaView style={styles.safeArea}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
</SafeAreaView>
</ThemedView>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
</PageLayout>
);
}
return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<PageLayout backgroundColor="#050505">
<StatusBar style="light" />
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<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>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#FFFFFF"
colors={['#FFFFFF']}
/>
}
onScroll={handleScroll}
scrollEventThrottle={LAYOUT_CONFIG.SCROLL_THROTTLE}
>
<Text style={styles.heading}>Content Generation</Text>
{Object.keys(groupedData).length === 0 ? (
<View style={styles.emptyContainer}>
@@ -323,45 +361,11 @@ export default function HistoryScreen() {
<Text style={styles.dateline}>{date}</Text>
<View style={styles.gallery}>
<View style={styles.leadingLane}>
{leftColumn.map(item => (
<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}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.status) }]} />
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
</View>
</TouchableOpacity>
))}
<ColumnRenderer items={leftColumn} />
</View>
<View style={styles.trailingLane}>
{rightColumn.map(item => (
<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}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.status) }]} />
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
</View>
</TouchableOpacity>
))}
<ColumnRenderer items={rightColumn} />
</View>
</View>
</View>
@@ -376,26 +380,18 @@ export default function HistoryScreen() {
</View>
)}
{!hasMore && allGenerations.length > 0 && (
<View style={styles.noMoreContainer}>
<Text style={styles.noMoreText}></Text>
</View>
)}
</ScrollView>
</SafeAreaView>
</ThemedView>
{!hasMore && allGenerations.length > 0 && (
<View style={styles.noMoreContainer}>
<Text style={styles.noMoreText}></Text>
</View>
)}
</ScrollView>
</PageLayout>
);
}
const styles = StyleSheet.create({
canvas: {
flex: 1,
},
safeArea: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 24,
paddingTop: 24,
paddingBottom: 48,
},

View File

@@ -1,6 +1,6 @@
import { router } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
import {
CategoryTabs,
@@ -69,16 +69,18 @@ function translateActivity(activity: Activity): FeatureItem | null {
export default function ExploreScreen() {
const { isAuthenticated } = useAuth();
const { requireAuth } = useAuthGuard();
const [activeCategory, setActiveCategory] = useState<string>();
const [activeCategory, setActiveCategory] = useState<string>('');
const [activityFeatures, setActivityFeatures] = useState<FeatureItem[]>([]);
const [categoryCollection, setCategoryCollection] = useState<CategoryWithChildren[]>([]);
const scrollViewRef = useRef<ScrollView>(null);
const categoryPositionsRef = useRef<Map<string, number>>(new Map());
const isScrollingProgrammaticallyRef = useRef(false);
useEffect(() => {
const hydrateFeatureCarousel = async () => {
try {
const activities = await getActivities({ isActive: true });
console.log({ activities })
const curatedFeatures = activities
.map(translateActivity)
.filter((feature): feature is FeatureItem => feature !== null);
@@ -92,9 +94,6 @@ export default function ExploreScreen() {
};
hydrateFeatureCarousel();
return () => {
};
}, []);
useEffect(() => {
@@ -157,65 +156,115 @@ export default function ExploreScreen() {
return options.length > 0 ? options : [];
}, [categoryCollection]);
const allCategoriesWithTemplates = useMemo(() => {
return categoryCollection.map((category) => {
const templates = (category.templates ?? [])
.map(translateTemplateToCommunity)
.filter((item): item is CommunityItem => item !== null);
const activeCategoryRecord = useMemo(() => {
return categoryCollection.find((category) => category.id === activeCategory) ?? null;
}, [categoryCollection, activeCategory]);
return {
id: category.id,
name: coalesceText(category.nameEn, category.name),
templates,
};
});
}, [categoryCollection]);
const templateCommunityItems = useMemo(() => {
if (!activeCategoryRecord) {
return [];
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (isScrollingProgrammaticallyRef.current) {
return;
}
return (activeCategoryRecord.templates ?? [])
.map(translateTemplateToCommunity)
.filter((item): item is CommunityItem => item !== null);
}, [activeCategoryRecord]);
const scrollY = event.nativeEvent.contentOffset.y;
const positions = Array.from(categoryPositionsRef.current.entries());
const featureItems = useMemo(() => {
return activityFeatures.map((item, index) => ({
...item,
id: `${activeCategory}-${item.id || index}`,
}));
}, [activityFeatures]);
let newActiveCategory = activeCategory;
for (let i = positions.length - 1; i >= 0; i--) {
const [categoryId, position] = positions[i];
if (scrollY >= position - 100) {
newActiveCategory = categoryId;
break;
}
}
const communityItems = useMemo(() => {
const source: CommunityItem[] =
templateCommunityItems.length > 0 ? templateCommunityItems : [];
if (newActiveCategory !== activeCategory) {
setActiveCategory(newActiveCategory);
}
}, [activeCategory]);
return source.map((item, index) => ({
...item,
id: `${activeCategory}-${item.id || index}`,
}));
}, [activeCategory, templateCommunityItems]);
const handleCategoryChange = useCallback((categoryId: string) => {
const targetPosition = categoryPositionsRef.current.get(categoryId);
if (targetPosition === undefined) {
return;
}
const handleGeneratePress = (item: CommunityItem) => {
isScrollingProgrammaticallyRef.current = true;
scrollViewRef.current?.scrollTo({
y: targetPosition,
animated: true,
});
setTimeout(() => {
isScrollingProgrammaticallyRef.current = false;
}, 500);
setActiveCategory(categoryId);
}, []);
const handleGeneratePress = useCallback((item: CommunityItem) => {
requireAuth(() => {
const templateId = item.id.split('-').pop() || item.id;
router.push(`/templates/${templateId}`);
});
};
}, [requireAuth]);
const handleFeaturePress = (item: FeatureItemType) => {
const handleFeaturePress = useCallback((item: FeatureItemType) => {
requireAuth(() => {
console.log('用户已登录,使用功能:', item.title);
// TODO: 实现功能逻辑
});
};
}, [requireAuth]);
return (
<PageLayout backgroundColor="#050505">
<PageLayout backgroundColor="#050505" topInset="none">
<StatusBarSpacer />
<Header />
<CategoryTabs
categories={categoryOptions}
activeId={activeCategory}
onChange={handleCategoryChange}
/>
<ScrollView
ref={scrollViewRef}
style={styles.scroll}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<StatusBarSpacer />
<Header />
<CategoryTabs categories={categoryOptions} activeId={activeCategory || ``} onChange={setActiveCategory} />
{featureItems.length > 0 && <FeatureCarousel items={featureItems} onPress={handleFeaturePress} />}
<SectionHeader title={activeCategoryRecord?.name || ``} />
<CommunityGrid items={communityItems} onPressAction={handleGeneratePress} />
<View style={styles.bottomSpacer} />
{activityFeatures.length > 0 && (
<FeatureCarousel
items={activityFeatures}
onPress={handleFeaturePress}
/>
)}
{allCategoriesWithTemplates.map((category, index) => (
<View
key={category.id}
onLayout={(event) => {
const layout = event.nativeEvent.layout;
categoryPositionsRef.current.set(category.id, layout.y);
}}
>
<SectionHeader title={category.name} />
<CommunityGrid
items={category.templates.map((template, idx) => ({
...template,
id: `${category.id}-${template.id || idx}`,
}))}
onPressAction={handleGeneratePress}
/>
</View>
))}
</ScrollView>
</PageLayout>
);
@@ -226,10 +275,6 @@ const styles = StyleSheet.create({
flex: 1,
},
contentContainer: {
paddingTop: 16,
paddingBottom: 48,
},
bottomSpacer: {
height: 32,
paddingBottom: 16,
},
});

5
app/(tabs)/todo.md Normal file
View File

@@ -0,0 +1,5 @@
/plan 分析并解决下面的问题
组件components\bestai\community-grid.tsx
瀑布流渲染时 并没有考虑目标图片的真实的宽高比,应该是真实的宽高比,才能保证图片、视频不变形。