feat: optimize home screen data loading with pagination and refresh functionality
This commit is contained in:
@@ -1,38 +1,63 @@
|
|||||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { useEffect } from 'react'
|
import React, { useEffect, useCallback, useState, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
FlatList,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
StatusBar as RNStatusBar,
|
StatusBar as RNStatusBar,
|
||||||
View
|
View,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
Dimensions,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
import { TitleBar, HeroSlider, TabNavigation, TemplateGrid } from '@/components/blocks/home'
|
import { TitleBar, HeroSlider, TabNavigation, TemplateCard } from '@/components/blocks/home'
|
||||||
import ErrorState from '@/components/ErrorState'
|
import ErrorState from '@/components/ErrorState'
|
||||||
import LoadingState from '@/components/LoadingState'
|
import LoadingState from '@/components/LoadingState'
|
||||||
import { useActivates } from '@/hooks/use-activates'
|
import { useActivates } from '@/hooks/use-activates'
|
||||||
import { useCategories } from '@/hooks/use-categories'
|
import { useCategoriesWithTags } from '@/hooks/use-categories-with-tags'
|
||||||
|
import { useCategoryTemplates } from '@/hooks/use-category-templates'
|
||||||
import { useStickyTabs } from '@/hooks/use-sticky-tabs'
|
import { useStickyTabs } from '@/hooks/use-sticky-tabs'
|
||||||
import { useTabNavigation } from '@/hooks/use-tab-navigation'
|
import { useTabNavigation } from '@/hooks/use-tab-navigation'
|
||||||
import { useTemplateFilter } from '@/hooks/use-template-filter'
|
import { useTemplateFilter } from '@/hooks/use-template-filter'
|
||||||
|
|
||||||
|
const NUM_COLUMNS = 3
|
||||||
|
const HORIZONTAL_PADDING = 16
|
||||||
|
const CARD_GAP = 5
|
||||||
|
const SCREEN_WIDTH = Dimensions.get('window').width
|
||||||
|
|
||||||
|
// 计算卡片宽度
|
||||||
|
const calculateCardWidth = () => {
|
||||||
|
return (SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - CARD_GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_WIDTH = calculateCardWidth()
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useLocalSearchParams()
|
const params = useLocalSearchParams()
|
||||||
|
|
||||||
// 数据加载
|
// 分类数据加载
|
||||||
const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories()
|
const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategoriesWithTags()
|
||||||
const { load: loadActivates, data: activatesData } = useActivates()
|
const { load: loadActivates, data: activatesData } = useActivates()
|
||||||
|
|
||||||
|
// 模板数据加载(分页)
|
||||||
|
const {
|
||||||
|
templates,
|
||||||
|
loading: templatesLoading,
|
||||||
|
loadingMore,
|
||||||
|
execute: loadTemplates,
|
||||||
|
loadMore,
|
||||||
|
hasMore,
|
||||||
|
} = useCategoryTemplates()
|
||||||
|
|
||||||
// 标签导航状态
|
// 标签导航状态
|
||||||
const {
|
const {
|
||||||
activeIndex,
|
activeIndex,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
currentCategory,
|
|
||||||
tabs,
|
tabs,
|
||||||
selectTab,
|
selectTab,
|
||||||
selectCategoryById,
|
selectCategoryById,
|
||||||
@@ -51,9 +76,9 @@ export default function HomeScreen() {
|
|||||||
handleTitleBarLayout,
|
handleTitleBarLayout,
|
||||||
} = useStickyTabs()
|
} = useStickyTabs()
|
||||||
|
|
||||||
// 模板过滤
|
// 模板过滤 - 使用 useMemo 缓存
|
||||||
const { filteredTemplates } = useTemplateFilter({
|
const { filteredTemplates } = useTemplateFilter({
|
||||||
templates: currentCategory?.templates || [],
|
templates,
|
||||||
excludeVideo: true,
|
excludeVideo: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,6 +88,13 @@ export default function HomeScreen() {
|
|||||||
loadActivates()
|
loadActivates()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 当分类变化时加载模板
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCategoryId) {
|
||||||
|
loadTemplates({ categoryId: selectedCategoryId })
|
||||||
|
}
|
||||||
|
}, [selectedCategoryId])
|
||||||
|
|
||||||
// 路由参数同步
|
// 路由参数同步
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.categoryId) {
|
if (params.categoryId) {
|
||||||
@@ -70,57 +102,72 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
}, [params.categoryId])
|
}, [params.categoryId])
|
||||||
|
|
||||||
// 状态判断
|
// 状态判断 - 使用 useMemo 缓存
|
||||||
const showLoading = categoriesLoading
|
const showLoading = categoriesLoading
|
||||||
const showEmptyState = !categoriesLoading && categoriesData?.categories?.length === 0
|
const showEmptyState = useMemo(() =>
|
||||||
const showEmptyTemplates = !categoriesLoading && !showEmptyState && filteredTemplates.length === 0
|
!categoriesLoading && categoriesData?.categories?.length === 0,
|
||||||
|
[categoriesLoading, categoriesData?.categories?.length]
|
||||||
|
)
|
||||||
|
const showEmptyTemplates = useMemo(() =>
|
||||||
|
!categoriesLoading && !templatesLoading && !showEmptyState && filteredTemplates.length === 0,
|
||||||
|
[categoriesLoading, templatesLoading, showEmptyState, filteredTemplates.length]
|
||||||
|
)
|
||||||
|
|
||||||
// 导航处理
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const handlePointsPress = () => router.push('/membership' as any)
|
|
||||||
const handleSearchPress = () => router.push('/searchTemplate')
|
// 下拉刷新处理
|
||||||
const handleActivityPress = (link: string) => router.push(link as any)
|
const handleRefresh = useCallback(async () => {
|
||||||
const handleArrowPress = () => router.push({
|
setRefreshing(true)
|
||||||
|
await Promise.all([
|
||||||
|
loadCategories(),
|
||||||
|
selectedCategoryId ? loadTemplates({ categoryId: selectedCategoryId }) : Promise.resolve(),
|
||||||
|
])
|
||||||
|
setRefreshing(false)
|
||||||
|
}, [selectedCategoryId, loadCategories, loadTemplates])
|
||||||
|
|
||||||
|
// 加载更多处理
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (!loadingMore && hasMore && !templatesLoading) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}, [loadingMore, hasMore, templatesLoading, loadMore])
|
||||||
|
|
||||||
|
// 导航处理 - 使用 useCallback memoize
|
||||||
|
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
|
||||||
|
const handleSearchPress = useCallback(() => router.push('/searchTemplate'), [router])
|
||||||
|
const handleActivityPress = useCallback((link: string) => router.push(link as any), [router])
|
||||||
|
const handleArrowPress = useCallback(() => router.push({
|
||||||
pathname: '/channels' as any,
|
pathname: '/channels' as any,
|
||||||
params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined,
|
params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined,
|
||||||
})
|
}), [router, selectedCategoryId])
|
||||||
const handleTemplatePress = (id: string) => router.push({
|
const handleTemplatePress = useCallback((id: string) => router.push({
|
||||||
pathname: '/templateDetail' as any,
|
pathname: '/templateDetail' as any,
|
||||||
params: { id },
|
params: { id },
|
||||||
})
|
}), [router])
|
||||||
|
|
||||||
|
// 渲染模板卡片
|
||||||
|
const renderTemplateItem = useCallback(({ item }: { item: typeof filteredTemplates[0] }) => {
|
||||||
|
if (!item.id) return null
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<TemplateCard
|
||||||
<StatusBar style="light" />
|
id={item.id}
|
||||||
<RNStatusBar barStyle="light-content" />
|
title={item.title}
|
||||||
|
previewUrl={item.previewUrl}
|
||||||
{/* 标题栏 */}
|
webpPreviewUrl={item.webpPreviewUrl}
|
||||||
<TitleBar
|
coverImageUrl={item.coverImageUrl}
|
||||||
onPointsPress={handlePointsPress}
|
aspectRatio={item.aspectRatio}
|
||||||
onSearchPress={handleSearchPress}
|
cardWidth={CARD_WIDTH}
|
||||||
onLayout={handleTitleBarLayout}
|
onPress={handleTemplatePress}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
}, [handleTemplatePress])
|
||||||
|
|
||||||
{/* 吸顶标签导航 */}
|
// 提取 key
|
||||||
{isSticky && !showLoading && !showEmptyState && (
|
const keyExtractor = useCallback((item: typeof filteredTemplates[0]) => item.id || '', [])
|
||||||
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
|
|
||||||
<TabNavigation
|
|
||||||
tabs={tabs}
|
|
||||||
activeIndex={activeIndex}
|
|
||||||
onTabPress={selectTab}
|
|
||||||
showArrow={true}
|
|
||||||
onArrowPress={handleArrowPress}
|
|
||||||
isSticky={true}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollView
|
// 列表头部组件
|
||||||
style={styles.scrollView}
|
const ListHeaderComponent = useMemo(() => (
|
||||||
contentContainerStyle={styles.scrollContent}
|
<>
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
|
|
||||||
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
|
|
||||||
>
|
|
||||||
{/* 活动轮播图 */}
|
{/* 活动轮播图 */}
|
||||||
<HeroSlider
|
<HeroSlider
|
||||||
activities={activatesData?.activities || []}
|
activities={activatesData?.activities || []}
|
||||||
@@ -148,7 +195,7 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
{/* 错误状态 */}
|
{/* 错误状态 */}
|
||||||
{categoriesError && (
|
{categoriesError && (
|
||||||
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} />
|
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} variant="error" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态 - 分类数据为空 */}
|
{/* 空状态 - 分类数据为空 */}
|
||||||
@@ -158,17 +205,118 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
{/* 空状态 - 当前分类下没有模板 */}
|
{/* 空状态 - 当前分类下没有模板 */}
|
||||||
{showEmptyTemplates && (
|
{showEmptyTemplates && (
|
||||||
<ErrorState message="该分类暂无模板" onRetry={() => loadCategories()} />
|
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 模板网格 */}
|
{/* 模板加载中 */}
|
||||||
{!showLoading && !showEmptyState && !showEmptyTemplates && (
|
{templatesLoading && !showLoading && (
|
||||||
<TemplateGrid
|
<View style={styles.templatesLoadingContainer}>
|
||||||
templates={filteredTemplates}
|
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||||
onTemplatePress={handleTemplatePress}
|
</View>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</>
|
||||||
|
), [
|
||||||
|
activatesData?.activities,
|
||||||
|
handleActivityPress,
|
||||||
|
showLoading,
|
||||||
|
showEmptyState,
|
||||||
|
isSticky,
|
||||||
|
tabsHeight,
|
||||||
|
tabs,
|
||||||
|
activeIndex,
|
||||||
|
selectTab,
|
||||||
|
handleArrowPress,
|
||||||
|
categoriesError,
|
||||||
|
showEmptyTemplates,
|
||||||
|
templatesLoading,
|
||||||
|
selectedCategoryId,
|
||||||
|
handleTabsLayout,
|
||||||
|
loadCategories,
|
||||||
|
loadTemplates,
|
||||||
|
])
|
||||||
|
|
||||||
|
// 列表底部组件
|
||||||
|
const ListFooterComponent = useMemo(() => {
|
||||||
|
if (loadingMore) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingMoreContainer}>
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [loadingMore])
|
||||||
|
|
||||||
|
// 获取有效的模板列表(过滤掉没有 id 的)
|
||||||
|
const validTemplates = useMemo(() =>
|
||||||
|
filteredTemplates.filter(t => !!t.id),
|
||||||
|
[filteredTemplates]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 只有在非加载状态且有数据时才显示列表
|
||||||
|
const showTemplateList = !showLoading && !showEmptyState && !showEmptyTemplates && !templatesLoading
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<RNStatusBar barStyle="light-content" />
|
||||||
|
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<TitleBar
|
||||||
|
onPointsPress={handlePointsPress}
|
||||||
|
onSearchPress={handleSearchPress}
|
||||||
|
onLayout={handleTitleBarLayout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 吸顶标签导航 */}
|
||||||
|
{isSticky && !showLoading && !showEmptyState && (
|
||||||
|
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
|
||||||
|
<TabNavigation
|
||||||
|
tabs={tabs}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onTabPress={selectTab}
|
||||||
|
showArrow={true}
|
||||||
|
onArrowPress={handleArrowPress}
|
||||||
|
isSticky={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={showTemplateList ? validTemplates : []}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
renderItem={renderTemplateItem}
|
||||||
|
numColumns={NUM_COLUMNS}
|
||||||
|
columnWrapperStyle={styles.columnWrapper}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
|
||||||
|
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
ListFooterComponent={ListFooterComponent}
|
||||||
|
removeClippedSubviews={Platform.OS === 'android'}
|
||||||
|
maxToRenderPerBatch={12}
|
||||||
|
windowSize={5}
|
||||||
|
initialNumToRender={12}
|
||||||
|
getItemLayout={(_, index) => ({
|
||||||
|
length: CARD_WIDTH * 1.5,
|
||||||
|
offset: CARD_WIDTH * 1.5 * Math.floor(index / NUM_COLUMNS),
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor="#9966FF"
|
||||||
|
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||||
|
progressBackgroundColor="#1C1E22"
|
||||||
|
progressViewOffset={10}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -183,9 +331,14 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: 20,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
|
columnWrapper: {
|
||||||
|
gap: CARD_GAP,
|
||||||
|
marginBottom: CARD_GAP,
|
||||||
|
},
|
||||||
stickyTabsWrapper: {
|
stickyTabsWrapper: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -194,4 +347,14 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
|
templatesLoadingContainer: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadingMoreContainer: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,21 +1,118 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { View, TouchableOpacity, ViewProps } from 'react-native'
|
import { View, Pressable, ViewProps, StyleSheet } from 'react-native'
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
|
import Svg, { Path, Circle, Defs, Rect, LinearGradient as SvgLinearGradient, Stop } from 'react-native-svg'
|
||||||
import Text from './ui/Text'
|
import Text from './ui/Text'
|
||||||
|
|
||||||
interface ErrorStateProps extends ViewProps {
|
interface ErrorStateProps extends ViewProps {
|
||||||
message?: string
|
message?: string
|
||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
|
/** 是否显示为空状态图标(默认)或错误图标 */
|
||||||
|
variant?: 'empty' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ErrorState({ message = 'An error occurred', onRetry, testID, ...props }: ErrorStateProps) {
|
/** 空状态图标 - 文件夹 */
|
||||||
|
function EmptyIcon() {
|
||||||
return (
|
return (
|
||||||
<View testID={testID} className="flex-1 items-center justify-center px-4" {...props}>
|
<Svg width={80} height={80} viewBox="0 0 80 80" fill="none">
|
||||||
<Text className="text-center text-gray-600 dark:text-gray-400">{message}</Text>
|
<Defs>
|
||||||
|
<SvgLinearGradient id="folderGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<Stop offset="0%" stopColor="#9966FF" stopOpacity={0.3} />
|
||||||
|
<Stop offset="100%" stopColor="#FF6699" stopOpacity={0.3} />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Rect x="10" y="20" width="60" height="45" rx="6" fill="url(#folderGrad)" stroke="#4A4A4A" strokeWidth="2" />
|
||||||
|
<Path d="M10 26C10 22.6863 12.6863 20 16 20H30L36 14H64C67.3137 14 70 16.6863 70 20V26H10Z" fill="#2A2A2A" stroke="#4A4A4A" strokeWidth="2" />
|
||||||
|
<Circle cx="40" cy="45" r="12" stroke="#6B6B6B" strokeWidth="2" strokeDasharray="4 4" />
|
||||||
|
<Path d="M36 45H44M40 41V49" stroke="#6B6B6B" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误状态图标 */
|
||||||
|
function ErrorIcon() {
|
||||||
|
return (
|
||||||
|
<Svg width={80} height={80} viewBox="0 0 80 80" fill="none">
|
||||||
|
<Circle cx="40" cy="40" r="28" stroke="#FF6B6B" strokeWidth="2" strokeOpacity={0.5} />
|
||||||
|
<Circle cx="40" cy="40" r="20" fill="#2A2A2A" stroke="#FF6B6B" strokeWidth="2" />
|
||||||
|
<Path d="M40 30V44" stroke="#FF6B6B" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
<Circle cx="40" cy="52" r="2" fill="#FF6B6B" />
|
||||||
|
</Svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorState({
|
||||||
|
message = 'An error occurred',
|
||||||
|
onRetry,
|
||||||
|
testID,
|
||||||
|
variant = 'empty',
|
||||||
|
...props
|
||||||
|
}: ErrorStateProps) {
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={styles.container} {...props}>
|
||||||
|
{/* 图标 */}
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
{variant === 'error' ? <ErrorIcon /> : <EmptyIcon />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 消息文本 */}
|
||||||
|
<Text style={styles.message}>{message}</Text>
|
||||||
|
|
||||||
|
{/* 重试按钮 - 使用渐变边框风格 */}
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<TouchableOpacity onPress={onRetry} className="mt-4 px-6 py-2 bg-blue-500 rounded-lg">
|
<Pressable onPress={onRetry} style={styles.retryButton}>
|
||||||
<Text className="text-white">Retry</Text>
|
<LinearGradient
|
||||||
</TouchableOpacity>
|
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.gradientBorder}
|
||||||
|
>
|
||||||
|
<View style={styles.buttonInner}>
|
||||||
|
<Text style={styles.buttonText}>重新加载</Text>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 48,
|
||||||
|
minHeight: 280,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#8E8E93',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 24,
|
||||||
|
borderRadius: 22,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
gradientBorder: {
|
||||||
|
padding: 1.5,
|
||||||
|
borderRadius: 22,
|
||||||
|
},
|
||||||
|
buttonInner: {
|
||||||
|
backgroundColor: '#1C1E22',
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: '#F5F5F5',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { memo } from 'react'
|
import React, { memo, useCallback, useMemo } from 'react'
|
||||||
import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'
|
import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'
|
||||||
import { Image } from 'expo-image'
|
import { Image } from 'expo-image'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
@@ -41,6 +41,11 @@ export function getImageUri(
|
|||||||
return webpPreviewUrl || previewUrl || coverImageUrl
|
return webpPreviewUrl || previewUrl || coverImageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渐变颜色常量,避免每次渲染创建新数组
|
||||||
|
const GRADIENT_COLORS = ['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)'] as const
|
||||||
|
const GRADIENT_START = { x: 0, y: 0 }
|
||||||
|
const GRADIENT_END = { x: 0, y: 1 }
|
||||||
|
|
||||||
const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
@@ -52,8 +57,26 @@ const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
}) => {
|
}) => {
|
||||||
const aspectRatio = parseAspectRatio(aspectRatioString)
|
const aspectRatio = useMemo(() => parseAspectRatio(aspectRatioString), [aspectRatioString])
|
||||||
const imageUri = getImageUri(webpPreviewUrl, previewUrl, coverImageUrl)
|
const imageUri = useMemo(() => getImageUri(webpPreviewUrl, previewUrl, coverImageUrl), [webpPreviewUrl, previewUrl, coverImageUrl])
|
||||||
|
|
||||||
|
// 使用 useCallback 缓存 onPress 回调
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (id) {
|
||||||
|
onPress(id)
|
||||||
|
}
|
||||||
|
}, [id, onPress])
|
||||||
|
|
||||||
|
// 缓存样式计算
|
||||||
|
const cardStyle = useMemo(() => [styles.card, { width: cardWidth }], [cardWidth])
|
||||||
|
const containerStyle = useMemo(() =>
|
||||||
|
[styles.cardImageContainer, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ViewStyle[],
|
||||||
|
[aspectRatio]
|
||||||
|
)
|
||||||
|
const imageStyle = useMemo(() =>
|
||||||
|
[styles.cardImage, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ViewStyle[],
|
||||||
|
[aspectRatio]
|
||||||
|
)
|
||||||
|
|
||||||
// 如果没有 id,则不渲染卡片
|
// 如果没有 id,则不渲染卡片
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -62,28 +85,23 @@ const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.card, { width: cardWidth }]}
|
style={cardStyle}
|
||||||
onPress={() => onPress(id)}
|
onPress={handlePress}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
>
|
>
|
||||||
<View
|
<View style={containerStyle}>
|
||||||
style={[
|
|
||||||
styles.cardImageContainer,
|
|
||||||
aspectRatio ? { aspectRatio } : undefined,
|
|
||||||
].filter(Boolean) as ViewStyle[]}
|
|
||||||
>
|
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: imageUri }}
|
source={{ uri: imageUri }}
|
||||||
style={[
|
style={imageStyle}
|
||||||
styles.cardImage,
|
|
||||||
aspectRatio ? { aspectRatio } : undefined,
|
|
||||||
].filter(Boolean) as ViewStyle[]}
|
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
recyclingKey={id}
|
||||||
|
transition={200}
|
||||||
/>
|
/>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
|
colors={GRADIENT_COLORS}
|
||||||
start={{ x: 0, y: 0 }}
|
start={GRADIENT_START}
|
||||||
end={{ x: 0, y: 1 }}
|
end={GRADIENT_END}
|
||||||
style={styles.cardImageGradient}
|
style={styles.cardImageGradient}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useState, memo } from 'react'
|
import React, { useState, memo } from 'react'
|
||||||
import { View, StyleSheet, LayoutChangeEvent } from 'react-native'
|
import { View, StyleSheet, LayoutChangeEvent } from 'react-native'
|
||||||
import type { CategoryTemplate } from '@repo/sdk'
|
import type { CategoryTemplate, TemplateDetail } from '@repo/sdk'
|
||||||
import { TemplateCard } from './TemplateCard'
|
import { TemplateCard } from './TemplateCard'
|
||||||
|
|
||||||
export type Template = CategoryTemplate
|
// 支持 CategoryTemplate 和 TemplateDetail 两种类型
|
||||||
|
export type Template = CategoryTemplate | TemplateDetail
|
||||||
|
|
||||||
export interface TemplateGridProps {
|
export interface TemplateGridProps {
|
||||||
templates: CategoryTemplate[]
|
templates: Template[]
|
||||||
onTemplatePress: (id: string) => void
|
onTemplatePress: (id: string) => void
|
||||||
numColumns?: number
|
numColumns?: number
|
||||||
horizontalPadding?: number
|
horizontalPadding?: number
|
||||||
@@ -40,7 +41,7 @@ const TemplateGridComponent: React.FC<TemplateGridProps> = ({
|
|||||||
const [gridWidth, setGridWidth] = useState(0)
|
const [gridWidth, setGridWidth] = useState(0)
|
||||||
|
|
||||||
// 过滤掉没有 id 的模板
|
// 过滤掉没有 id 的模板
|
||||||
const validTemplates = templates.filter((template): template is CategoryTemplate & { id: string } =>
|
const validTemplates = templates.filter((template): template is Template & { id: string } =>
|
||||||
!!template.id
|
!!template.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { type ApiError } from '@/lib/types'
|
|||||||
import { OWNER_ID } from '@/lib/auth'
|
import { OWNER_ID } from '@/lib/auth'
|
||||||
import { handleError } from './use-error'
|
import { handleError } from './use-error'
|
||||||
|
|
||||||
type ListCategoryTemplatesParams = Omit<ListTemplatesInput, 'ownerId'>
|
type ListCategoryTemplatesParams = Partial<Omit<ListTemplatesInput, 'ownerId'>>
|
||||||
|
|
||||||
interface UseCategoryTemplatesOptions {
|
interface UseCategoryTemplatesOptions {
|
||||||
categoryId?: string
|
categoryId?: string
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import type { CategoryTemplate } from '@repo/sdk'
|
import type { CategoryTemplate, TemplateDetail } from '@repo/sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template type that can be filtered (supports both CategoryTemplate and TemplateDetail)
|
||||||
|
*/
|
||||||
|
type FilterableTemplate = CategoryTemplate | TemplateDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for useTemplateFilter hook
|
* Options for useTemplateFilter hook
|
||||||
*/
|
*/
|
||||||
export interface UseTemplateFilterOptions {
|
export interface UseTemplateFilterOptions<T extends FilterableTemplate = FilterableTemplate> {
|
||||||
templates: CategoryTemplate[]
|
templates: T[]
|
||||||
excludeVideo?: boolean
|
excludeVideo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return type for useTemplateFilter hook
|
* Return type for useTemplateFilter hook
|
||||||
*/
|
*/
|
||||||
export interface UseTemplateFilterReturn {
|
export interface UseTemplateFilterReturn<T extends FilterableTemplate = FilterableTemplate> {
|
||||||
filteredTemplates: CategoryTemplate[]
|
filteredTemplates: T[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +34,7 @@ const VIDEO_EXTENSIONS = ['mp4', 'mov', 'webm']
|
|||||||
* @param options.excludeVideo - Whether to exclude video templates (default: false)
|
* @param options.excludeVideo - Whether to exclude video templates (default: false)
|
||||||
* @returns Object containing filtered templates
|
* @returns Object containing filtered templates
|
||||||
*/
|
*/
|
||||||
export function useTemplateFilter(options: UseTemplateFilterOptions): UseTemplateFilterReturn {
|
export function useTemplateFilter<T extends FilterableTemplate>(options: UseTemplateFilterOptions<T>): UseTemplateFilterReturn<T> {
|
||||||
const { templates, excludeVideo = false } = options
|
const { templates, excludeVideo = false } = options
|
||||||
|
|
||||||
const filteredTemplates = useMemo(() => {
|
const filteredTemplates = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user