541 lines
13 KiB
TypeScript
541 lines
13 KiB
TypeScript
import { PageLayout } from '@/components/bestai/layout';
|
|
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
|
|
import { router } from 'expo-router';
|
|
import { StatusBar } from 'expo-status-bar';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Image,
|
|
Platform,
|
|
RefreshControl,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TouchableOpacity,
|
|
View
|
|
} from 'react-native';
|
|
|
|
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[];
|
|
}
|
|
|
|
/**
|
|
* 根据文件 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 = {};
|
|
|
|
data.forEach(item => {
|
|
const date = new Date(item.createdAt);
|
|
const dateKey = date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
weekday: 'short'
|
|
});
|
|
|
|
if (!groups[dateKey]) {
|
|
groups[dateKey] = [];
|
|
}
|
|
groups[dateKey].push(item);
|
|
});
|
|
|
|
return groups;
|
|
};
|
|
|
|
const formatDateLabel = (dateStr: string): string => {
|
|
const date = new Date(dateStr);
|
|
const today = new Date();
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
|
|
const dateOnly = new Date(date.toDateString());
|
|
const todayOnly = new Date(today.toDateString());
|
|
const yesterdayOnly = new Date(yesterday.toDateString());
|
|
|
|
if (dateOnly.getTime() === todayOnly.getTime()) {
|
|
return 'Today';
|
|
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
|
|
return 'Yesterday';
|
|
} else {
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
weekday: 'short'
|
|
});
|
|
}
|
|
};
|
|
|
|
const distributeToColumns = (items: TemplateGeneration[]) => {
|
|
const leftColumn: TemplateGeneration[] = [];
|
|
const rightColumn: TemplateGeneration[] = [];
|
|
let leftHeight = 0;
|
|
let rightHeight = 0;
|
|
|
|
items.forEach(item => {
|
|
const height = item.type === 'VIDEO' ? LAYOUT_CONFIG.VIDEO_HEIGHT : LAYOUT_CONFIG.IMAGE_HEIGHT;
|
|
|
|
if (leftHeight <= rightHeight) {
|
|
leftColumn.push(item);
|
|
leftHeight += height + LAYOUT_CONFIG.COLUMN_GAP;
|
|
} else {
|
|
rightColumn.push(item);
|
|
rightHeight += height + LAYOUT_CONFIG.COLUMN_GAP;
|
|
}
|
|
});
|
|
|
|
return { leftColumn, rightColumn };
|
|
};
|
|
|
|
const getImageHeight = (type: string): number => {
|
|
switch (type) {
|
|
case 'VIDEO':
|
|
return LAYOUT_CONFIG.VIDEO_HEIGHT;
|
|
case 'IMAGE':
|
|
return LAYOUT_CONFIG.IMAGE_HEIGHT;
|
|
default:
|
|
return LAYOUT_CONFIG.DEFAULT_HEIGHT;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string): string => {
|
|
switch (status) {
|
|
case 'completed':
|
|
return '#4CAF50';
|
|
case 'processing':
|
|
case 'pending':
|
|
return '#FFA726';
|
|
case 'failed':
|
|
return '#EF5350';
|
|
default:
|
|
return '#9E9E9E';
|
|
}
|
|
};
|
|
|
|
const getStatusText = (status: string): string => {
|
|
switch (status) {
|
|
case 'completed':
|
|
return '已完成';
|
|
case 'processing':
|
|
case 'pending':
|
|
return '处理中';
|
|
case 'failed':
|
|
return '失败';
|
|
default:
|
|
return status;
|
|
}
|
|
};
|
|
|
|
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 [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 groupedData = useMemo(() => groupByDate(allGenerations), [allGenerations]);
|
|
|
|
const fetchData = useCallback(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: LAYOUT_CONFIG.PAGE_SIZE });
|
|
|
|
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 === LAYOUT_CONFIG.PAGE_SIZE);
|
|
setError(null);
|
|
} else {
|
|
setError('获取数据失败');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch data:', err);
|
|
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) {
|
|
const nextPage = page + 1;
|
|
setPage(nextPage);
|
|
fetchData(nextPage, false);
|
|
}
|
|
}, [loadingMore, hasMore, page, loading, fetchData]);
|
|
|
|
const handleScroll = useCallback((event: any) => {
|
|
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
|
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - LAYOUT_CONFIG.LOAD_MORE_THRESHOLD;
|
|
|
|
if (isCloseToBottom && !loadingMore && hasMore) {
|
|
loadMore();
|
|
}
|
|
}, [loadingMore, hasMore, loadMore]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<PageLayout backgroundColor="#050505">
|
|
<StatusBar style="light" />
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color="#FFFFFF" />
|
|
<Text style={styles.loadingText}>加载中...</Text>
|
|
</View>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<PageLayout backgroundColor="#050505">
|
|
<StatusBar style="light" />
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
</View>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageLayout backgroundColor="#050505">
|
|
<StatusBar style="light" />
|
|
<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}>
|
|
<Text style={styles.emptyText}>暂无生成记录</Text>
|
|
</View>
|
|
) : (
|
|
Object.entries(groupedData).map(([date, items]) => {
|
|
const { leftColumn, rightColumn } = distributeToColumns(items);
|
|
|
|
return (
|
|
<View key={date} style={styles.dateGroup}>
|
|
<Text style={styles.dateline}>{date}</Text>
|
|
<View style={styles.gallery}>
|
|
<View style={styles.leadingLane}>
|
|
<ColumnRenderer items={leftColumn} />
|
|
</View>
|
|
|
|
<View style={styles.trailingLane}>
|
|
<ColumnRenderer items={rightColumn} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{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>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
scrollContent: {
|
|
paddingTop: 14,
|
|
paddingBottom: 14,
|
|
},
|
|
heading: {
|
|
fontSize: 24,
|
|
fontWeight: '600',
|
|
color: '#FFFFFF',
|
|
textAlign: 'center',
|
|
letterSpacing: 0.4,
|
|
},
|
|
dateline: {
|
|
marginTop: 16,
|
|
marginBottom: 16,
|
|
fontSize: 16,
|
|
fontWeight: '500',
|
|
color: '#EDEDED',
|
|
},
|
|
gallery: {
|
|
flexDirection: 'row',
|
|
marginBottom: 24,
|
|
},
|
|
leadingLane: {
|
|
flex: 1,
|
|
paddingRight: 12,
|
|
},
|
|
trailingLane: {
|
|
flex: 1,
|
|
paddingLeft: 12,
|
|
},
|
|
frame: {
|
|
width: '100%',
|
|
borderRadius: 16,
|
|
marginBottom: 16,
|
|
overflow: 'hidden',
|
|
backgroundColor: '#1A1A1A',
|
|
},
|
|
image: {
|
|
width: '100%',
|
|
},
|
|
placeholderImage: {
|
|
backgroundColor: '#2A2A2A',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
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.7)',
|
|
borderBottomLeftRadius: 16,
|
|
borderBottomRightRadius: 16,
|
|
},
|
|
statusBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginBottom: 4,
|
|
},
|
|
statusDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
marginRight: 6,
|
|
},
|
|
statusText: {
|
|
fontSize: 12,
|
|
color: '#FFFFFF',
|
|
fontWeight: '500',
|
|
},
|
|
templateName: {
|
|
fontSize: 13,
|
|
color: '#FFFFFF',
|
|
fontWeight: '600',
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
loadingText: {
|
|
marginTop: 16,
|
|
fontSize: 16,
|
|
color: '#FFFFFF',
|
|
},
|
|
errorContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 24,
|
|
},
|
|
errorText: {
|
|
fontSize: 16,
|
|
color: '#EF5350',
|
|
textAlign: 'center',
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 48,
|
|
},
|
|
emptyText: {
|
|
fontSize: 16,
|
|
color: '#9E9E9E',
|
|
textAlign: 'center',
|
|
},
|
|
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',
|
|
},
|
|
});
|