feat: integrate UI components into Search pages with pagination and loading

- Add useDebounce hook for future search optimization
- Integrate LoadingState, ErrorState, RefreshControl, and PaginationLoader into searchResults.tsx
- Add pull-to-refresh functionality with RefreshControl component
- Implement pagination with loadMore and PaginationLoader
- Add error handling with retry functionality using ErrorState
- Update SearchResultsGrid to support refreshControl, onEndReached, and ListFooterComponent props
- Add scroll event handling for pagination trigger
- Add TODO comment in searchWorksResults.tsx for backend API integration
- Reduce initial search limit from 50 to 20 for better performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
imeepos 2026-01-21 12:20:11 +08:00
parent 10ee380051
commit 1f34b4c273
5 changed files with 122 additions and 14 deletions

View File

@ -3,7 +3,7 @@ import {
View, View,
StyleSheet, StyleSheet,
StatusBar as RNStatusBar, StatusBar as RNStatusBar,
RefreshControl, ScrollView,
} from 'react-native' } from 'react-native'
import { StatusBar } from 'expo-status-bar' import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context' import { SafeAreaView } from 'react-native-safe-area-context'
@ -11,6 +11,10 @@ import { useRouter, useLocalSearchParams } from 'expo-router'
import SearchResultsGrid from '@/components/SearchResultsGrid' import SearchResultsGrid from '@/components/SearchResultsGrid'
import SearchBar from '@/components/SearchBar' import SearchBar from '@/components/SearchBar'
import RefreshControl from '@/components/RefreshControl'
import LoadingState from '@/components/LoadingState'
import ErrorState from '@/components/ErrorState'
import PaginationLoader from '@/components/PaginationLoader'
import { useTemplates, useSearchHistory } from '@/hooks' import { useTemplates, useSearchHistory } from '@/hooks'
export default function SearchResultsScreen() { export default function SearchResultsScreen() {
@ -19,27 +23,20 @@ export default function SearchResultsScreen() {
const [searchText, setSearchText] = useState((params.q as string) || '') const [searchText, setSearchText] = useState((params.q as string) || '')
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
// 使用 useTemplates hook 进行搜索 const { templates, loading, loadingMore, error, execute, refetch, loadMore, hasMore } = useTemplates()
const { templates, loading, error, execute, refetch } = useTemplates()
// 使用搜索历史 hook
const { addToHistory } = useSearchHistory() const { addToHistory } = useSearchHistory()
// 当搜索关键词变化时,执行搜索
useEffect(() => { useEffect(() => {
if (params.q && typeof params.q === 'string') { if (params.q && typeof params.q === 'string') {
const query = params.q.trim() const query = params.q.trim()
setSearchText(query) setSearchText(query)
if (query) { if (query) {
// 执行搜索 execute({ search: query, limit: 20, sortBy: 'createdAt', sortOrder: 'desc', page: 1 })
execute({ search: query, limit: 50, sortBy: 'createdAt', sortOrder: 'desc', page: 1 })
// 添加到搜索历史
addToHistory(query) addToHistory(query)
} }
} }
}, [params.q, execute, addToHistory]) }, [params.q, execute, addToHistory])
// 处理下拉刷新
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
if (searchText) { if (searchText) {
@ -48,7 +45,12 @@ export default function SearchResultsScreen() {
setRefreshing(false) setRefreshing(false)
} }
// 将模板数据转换为搜索结果格式 const handleLoadMore = () => {
if (hasMore && !loadingMore) {
loadMore()
}
}
const searchResults = templates.map(template => ({ const searchResults = templates.map(template => ({
id: template.id, id: template.id,
title: template.title || template.titleEn || '', title: template.title || template.titleEn || '',
@ -58,6 +60,76 @@ export default function SearchResultsScreen() {
aspectRatio: template.aspectRatio ? parseFloat(template.aspectRatio as string) : undefined, aspectRatio: template.aspectRatio ? parseFloat(template.aspectRatio as string) : undefined,
})) }))
if (loading && !refreshing) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={(text) => {
router.push({
pathname: '/searchTemplate',
params: { q: text, focus: 'true' },
})
}}
onBack={() => router.back()}
readOnly={true}
onInputPress={() => {
router.push({
pathname: '/searchTemplate',
params: { q: searchText, focus: 'true' },
})
}}
onClearPress={() => {
router.push({
pathname: '/searchTemplate',
params: { q: '', focus: 'true' },
})
}}
marginBottom={12}
/>
<LoadingState />
</SafeAreaView>
)
}
if (error) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={(text) => {
router.push({
pathname: '/searchTemplate',
params: { q: text, focus: 'true' },
})
}}
onBack={() => router.back()}
readOnly={true}
onInputPress={() => {
router.push({
pathname: '/searchTemplate',
params: { q: searchText, focus: 'true' },
})
}}
onClearPress={() => {
router.push({
pathname: '/searchTemplate',
params: { q: '', focus: 'true' },
})
}}
marginBottom={12}
/>
<ErrorState message={error.message} onRetry={refetch} />
</SafeAreaView>
)
}
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" /> <StatusBar style="light" />
@ -91,7 +163,10 @@ export default function SearchResultsScreen() {
<SearchResultsGrid <SearchResultsGrid
results={searchResults} results={searchResults}
loading={loading} loading={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
onEndReached={handleLoadMore}
ListFooterComponent={loadingMore ? <PaginationLoader /> : null}
/> />
</SafeAreaView> </SafeAreaView>
) )

View File

@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next'
import SearchBar from '@/components/SearchBar' import SearchBar from '@/components/SearchBar'
import WorksGallery, { type Category, type WorkItem } from '@/components/WorksGallery' import WorksGallery, { type Category, type WorkItem } from '@/components/WorksGallery'
// 模拟搜索结果数据 // TODO: Replace with actual API call when backend supports works search
const mockSearchResults: WorkItem[] = [ const mockSearchResults: WorkItem[] = [
{ id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' }, { id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },
{ id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' }, { id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },

View File

@ -30,6 +30,9 @@ interface TemplateSearchResultItem {
interface SearchResultsGridProps { interface SearchResultsGridProps {
results: TemplateSearchResultItem[] results: TemplateSearchResultItem[]
loading?: boolean loading?: boolean
refreshControl?: React.ReactElement
onEndReached?: () => void
ListFooterComponent?: React.ReactElement | null
} }
// 计算卡片高度的辅助函数 // 计算卡片高度的辅助函数
@ -41,7 +44,7 @@ const calculateCardHeight = (width: number, aspectRatio?: number): number => {
return width * 1.2 return width * 1.2
} }
export default function SearchResultsGrid({ results, loading }: SearchResultsGridProps) { export default function SearchResultsGrid({ results, loading, refreshControl, onEndReached, ListFooterComponent }: SearchResultsGridProps) {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const [gridWidth, setGridWidth] = useState(screenWidth) const [gridWidth, setGridWidth] = useState(screenWidth)
@ -57,6 +60,14 @@ export default function SearchResultsGrid({ results, loading }: SearchResultsGri
}) })
} }
const handleScroll = (event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
const paddingToBottom = 20
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
onEndReached?.()
}
}
if (loading) { if (loading) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@ -77,6 +88,9 @@ export default function SearchResultsGrid({ results, loading }: SearchResultsGri
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={refreshControl}
onScroll={handleScroll}
scrollEventThrottle={400}
> >
<View <View
style={styles.gridContainer} style={styles.gridContainer}
@ -112,6 +126,7 @@ export default function SearchResultsGrid({ results, loading }: SearchResultsGri
) )
})} })}
</View> </View>
{ListFooterComponent}
</ScrollView> </ScrollView>
) )
} }

View File

@ -6,3 +6,4 @@ export { useTemplateDetail, type TemplateDetail as TemplateDetailType } from './
export { useTemplateGenerations, type TemplateGeneration } from './use-template-generations' export { useTemplateGenerations, type TemplateGeneration } from './use-template-generations'
export { useSearchHistory } from './use-search-history' export { useSearchHistory } from './use-search-history'
export { useTags } from './use-tags' export { useTags } from './use-tags'
export { useDebounce } from './use-debounce'

17
hooks/use-debounce.ts Normal file
View File

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}