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:
parent
10ee380051
commit
1f34b4c273
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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: '写真' },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue