feat: integrate UI components into Generation Record page with pagination

Transform Generation Record from static detail view to dynamic list with:
- useTemplateGenerations hook for data fetching
- RefreshControl for pull-to-refresh functionality
- LoadingState and ErrorState for proper state handling
- PaginationLoader for infinite scroll
- FlatList with onEndReached for pagination
- Comprehensive test coverage following TDD principles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
imeepos
2026-01-21 12:11:31 +08:00
parent e0a56710d0
commit beba2f2428
2 changed files with 408 additions and 85 deletions

View File

@@ -0,0 +1,276 @@
import React from 'react'
import { render, fireEvent, waitFor } from '@testing-library/react-native'
import { View } from 'react-native'
import GenerationRecordScreen from './generationRecord'
jest.mock('expo-router', () => ({
useRouter: jest.fn(() => ({
back: jest.fn(),
push: jest.fn(),
})),
}))
jest.mock('react-i18next', () => ({
useTranslation: jest.fn(() => ({
t: (key: string) => key,
})),
}))
jest.mock('expo-status-bar', () => ({
StatusBar: 'StatusBar',
}))
jest.mock('react-native-safe-area-context', () => ({
SafeAreaView: ({ children }: { children: React.ReactNode }) => (
<View>{children}</View>
),
}))
jest.mock('expo-image', () => ({
Image: 'Image',
}))
jest.mock('@/components/icon', () => ({
LeftArrowIcon: () => null,
DeleteIcon: () => null,
EditIcon: () => null,
ChangeIcon: () => null,
WhiteStarIcon: () => null,
}))
jest.mock('@/components/ui/delete-confirm-dialog', () => ({
DeleteConfirmDialog: () => null,
}))
jest.mock('@/components/LoadingState', () => ({
__esModule: true,
default: ({ testID }: any) => <View testID={testID} />,
}))
jest.mock('@/components/ErrorState', () => ({
__esModule: true,
default: ({ testID }: any) => <View testID={testID} />,
}))
jest.mock('@/components/PaginationLoader', () => ({
__esModule: true,
default: ({ testID }: any) => <View testID={testID} />,
}))
jest.mock('@/components/RefreshControl', () => ({
__esModule: true,
default: () => null,
}))
jest.mock('@/hooks', () => ({
useTemplateGenerations: jest.fn(),
}))
import { useTemplateGenerations } from '@/hooks'
const mockUseTemplateGenerations = useTemplateGenerations as jest.MockedFunction<typeof useTemplateGenerations>
describe('GenerationRecordScreen', () => {
const mockExecute = jest.fn()
const mockRefetch = jest.fn()
const mockLoadMore = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
it('shows loading state on initial load', () => {
mockUseTemplateGenerations.mockReturnValue({
generations: [],
loading: true,
loadingMore: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: true,
data: undefined,
})
const { getByTestId } = render(<GenerationRecordScreen />)
expect(getByTestId('loading-state')).toBeTruthy()
})
it('shows error state when error occurs', () => {
mockUseTemplateGenerations.mockReturnValue({
generations: [],
loading: false,
loadingMore: false,
error: { message: 'Failed to load' } as any,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: false,
data: undefined,
})
const { getByTestId } = render(<GenerationRecordScreen />)
expect(getByTestId('error-state')).toBeTruthy()
})
it('renders generation list when data is loaded', () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
originalUrl: 'https://example.com/original.jpg',
},
]
mockUseTemplateGenerations.mockReturnValue({
generations: mockGenerations as any,
loading: false,
loadingMore: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: true,
data: { data: mockGenerations } as any,
})
const { getByText } = render(<GenerationRecordScreen />)
expect(getByText('Test Template')).toBeTruthy()
})
it('shows pagination loader when loading more', () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
},
]
mockUseTemplateGenerations.mockReturnValue({
generations: mockGenerations as any,
loading: false,
loadingMore: true,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: true,
data: { data: mockGenerations } as any,
})
const { getByTestId } = render(<GenerationRecordScreen />)
expect(getByTestId('pagination-loader')).toBeTruthy()
})
it('calls execute on mount', () => {
mockUseTemplateGenerations.mockReturnValue({
generations: [],
loading: false,
loadingMore: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: false,
data: undefined,
})
render(<GenerationRecordScreen />)
expect(mockExecute).toHaveBeenCalledWith({ page: 1, limit: 20 })
})
it('calls refetch on pull to refresh', async () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
},
]
mockUseTemplateGenerations.mockReturnValue({
generations: mockGenerations as any,
loading: false,
loadingMore: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: true,
data: { data: mockGenerations } as any,
})
const { getByTestId } = render(<GenerationRecordScreen />)
const flatList = getByTestId('generation-list')
fireEvent(flatList, 'refresh')
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 })
})
})
it('calls loadMore when reaching end of list', async () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
},
]
mockUseTemplateGenerations.mockReturnValue({
generations: mockGenerations as any,
loading: false,
loadingMore: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: true,
data: { data: mockGenerations } as any,
})
const { getByTestId } = render(<GenerationRecordScreen />)
const flatList = getByTestId('generation-list')
fireEvent(flatList, 'endReached')
await waitFor(() => {
expect(mockLoadMore).toHaveBeenCalledWith({ limit: 20 })
})
})
it('does not call loadMore when hasMore is false', async () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
},
]
mockUseTemplateGenerations.mockReturnValue({
generations: mockGenerations as any,
loading: false,
loadingMore: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: false,
data: { data: mockGenerations } as any,
})
const { getByTestId } = render(<GenerationRecordScreen />)
const flatList = getByTestId('generation-list')
fireEvent(flatList, 'endReached')
await waitFor(() => {
expect(mockLoadMore).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,7 +2,7 @@ import {
View,
Text,
StyleSheet,
ScrollView,
FlatList,
Dimensions,
Pressable,
StatusBar as RNStatusBar,
@@ -10,107 +10,156 @@ import {
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon, DeleteIcon, EditIcon, ChangeIcon, WhiteStarIcon } from '@/components/icon'
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'
import LoadingState from '@/components/LoadingState'
import ErrorState from '@/components/ErrorState'
import PaginationLoader from '@/components/PaginationLoader'
import RefreshControl from '@/components/RefreshControl'
import { useTemplateGenerations, type TemplateGeneration } from '@/hooks'
const { width: screenWidth } = Dimensions.get('window')
export default function GenerationRecordScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const workId = params.id as string
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const { generations, loading, loadingMore, error, execute, refetch, loadMore, hasMore } = useTemplateGenerations()
useEffect(() => {
execute({ page: 1, limit: 20 })
}, [execute])
const handleDelete = () => {
// TODO: 实现删除逻辑
console.log('删除记录:', workId)
console.log('删除记录:', selectedId)
setDeleteDialogOpen(false)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 顶部导航栏 */}
const handleRefresh = () => {
refetch({ page: 1, limit: 20 })
}
const handleLoadMore = () => {
if (hasMore && !loadingMore) {
loadMore({ limit: 20 })
}
}
if (loading && generations.length === 0) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<LeftArrowIcon />
</Pressable>
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
<View style={styles.headerSpacer} />
</View>
<LoadingState testID="loading-state" />
</SafeAreaView>
)
}
{/* AI 视频标签 */}
<View style={styles.categorySection}>
<View style={styles.categoryIcon}>
<WhiteStarIcon />
</View>
<Text style={styles.categoryText}>{t('generationRecord.aiVideo')}</Text>
</View>
{/* 原图标签 */}
<View style={styles.originalImageSection}>
<Text style={styles.originalImageLabel}>{t('generationRecord.originalImage')}</Text>
<View style={styles.originalImageDivider} />
</View>
{/* 主图片/视频预览区域 */}
<View style={styles.imageContainer}>
<Image
source={require('@/assets/images/android-icon-background.png')}
style={styles.mainImage}
contentFit="cover"
/>
{/* 视频时长显示 */}
</View>
<Text style={styles.durationText}>00:03</Text>
{/* 底部操作按钮 */}
<View style={styles.actionButtons}>
<Pressable style={styles.actionButton} onPress={() => {
router.push({
pathname: '/generateVideo' as any,
params: {
template: JSON.stringify({
id: 1,
title: 'AI 视频',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 214,
}),
},
})
}}>
<EditIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.reEdit')}</Text>
</Pressable>
<Pressable style={styles.actionButton}>
<ChangeIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.regenerate')}</Text>
</Pressable>
<Pressable
style={styles.deleteButton}
onPress={() => setDeleteDialogOpen(true)}
>
<DeleteIcon />
if (error && generations.length === 0) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<LeftArrowIcon />
</Pressable>
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
<View style={styles.headerSpacer} />
</View>
</ScrollView>
<ErrorState testID="error-state" message={error.message} onRetry={handleRefresh} />
</SafeAreaView>
)
}
const renderItem = ({ item }: { item: TemplateGeneration }) => (
<View style={styles.itemContainer}>
<View style={styles.categorySection}>
<View style={styles.categoryIcon}>
<WhiteStarIcon />
</View>
<Text style={styles.categoryText}>{item.template?.title || t('generationRecord.aiVideo')}</Text>
</View>
<View style={styles.originalImageSection}>
<Text style={styles.originalImageLabel}>{t('generationRecord.originalImage')}</Text>
<View style={styles.originalImageDivider} />
</View>
<View style={styles.imageContainer}>
<Image
source={{ uri: item.resultUrl?.[0] || item.originalUrl }}
style={styles.mainImage}
contentFit="cover"
/>
</View>
<Text style={styles.durationText}>00:03</Text>
<View style={styles.actionButtons}>
<Pressable style={styles.actionButton} onPress={() => {
router.push({
pathname: '/generateVideo' as any,
params: { template: JSON.stringify(item.template) },
})
}}>
<EditIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.reEdit')}</Text>
</Pressable>
<Pressable style={styles.actionButton}>
<ChangeIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.regenerate')}</Text>
</Pressable>
<Pressable
style={styles.deleteButton}
onPress={() => {
setSelectedId(item.id)
setDeleteDialogOpen(true)
}}
>
<DeleteIcon />
</Pressable>
</View>
</View>
)
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<LeftArrowIcon />
</Pressable>
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
<View style={styles.headerSpacer} />
</View>
<FlatList
testID="generation-list"
data={generations}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={loading} onRefresh={handleRefresh} />}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={loadingMore ? <PaginationLoader testID="pagination-loader" /> : null}
/>
{/* 删除确认弹窗 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
@@ -125,12 +174,8 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#090A0B',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
paddingBottom: 20,
},
header: {
flexDirection: 'row',
@@ -155,12 +200,15 @@ const styles = StyleSheet.create({
headerSpacer: {
width: 22,
},
itemContainer: {
marginBottom: 24,
},
categorySection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
gap:4,
gap: 4,
},
categoryIcon: {
width: 16,
@@ -191,13 +239,12 @@ const styles = StyleSheet.create({
},
imageContainer: {
width: screenWidth - 24,
height: (screenWidth - 24) * 1.32, // 根据设计稿比例计算
height: (screenWidth - 24) * 1.32,
marginHorizontal: 12,
marginBottom: 14,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#1C1E22',
position: 'relative',
},
mainImage: {
width: '100%',