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 { StatusBar } from 'expo-status-bar'
|
||||
import { useEffect } from 'react'
|
||||
import React, { useEffect, useCallback, useState, useMemo } from 'react'
|
||||
import {
|
||||
Platform,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
StatusBar as RNStatusBar,
|
||||
View
|
||||
View,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Dimensions,
|
||||
} from 'react-native'
|
||||
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 LoadingState from '@/components/LoadingState'
|
||||
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 { useTabNavigation } from '@/hooks/use-tab-navigation'
|
||||
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() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
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 {
|
||||
templates,
|
||||
loading: templatesLoading,
|
||||
loadingMore,
|
||||
execute: loadTemplates,
|
||||
loadMore,
|
||||
hasMore,
|
||||
} = useCategoryTemplates()
|
||||
|
||||
// 标签导航状态
|
||||
const {
|
||||
activeIndex,
|
||||
selectedCategoryId,
|
||||
currentCategory,
|
||||
tabs,
|
||||
selectTab,
|
||||
selectCategoryById,
|
||||
@@ -51,9 +76,9 @@ export default function HomeScreen() {
|
||||
handleTitleBarLayout,
|
||||
} = useStickyTabs()
|
||||
|
||||
// 模板过滤
|
||||
// 模板过滤 - 使用 useMemo 缓存
|
||||
const { filteredTemplates } = useTemplateFilter({
|
||||
templates: currentCategory?.templates || [],
|
||||
templates,
|
||||
excludeVideo: true,
|
||||
})
|
||||
|
||||
@@ -63,6 +88,13 @@ export default function HomeScreen() {
|
||||
loadActivates()
|
||||
}, [])
|
||||
|
||||
// 当分类变化时加载模板
|
||||
useEffect(() => {
|
||||
if (selectedCategoryId) {
|
||||
loadTemplates({ categoryId: selectedCategoryId })
|
||||
}
|
||||
}, [selectedCategoryId])
|
||||
|
||||
// 路由参数同步
|
||||
useEffect(() => {
|
||||
if (params.categoryId) {
|
||||
@@ -70,57 +102,72 @@ export default function HomeScreen() {
|
||||
}
|
||||
}, [params.categoryId])
|
||||
|
||||
// 状态判断
|
||||
// 状态判断 - 使用 useMemo 缓存
|
||||
const showLoading = categoriesLoading
|
||||
const showEmptyState = !categoriesLoading && categoriesData?.categories?.length === 0
|
||||
const showEmptyTemplates = !categoriesLoading && !showEmptyState && filteredTemplates.length === 0
|
||||
const showEmptyState = useMemo(() =>
|
||||
!categoriesLoading && categoriesData?.categories?.length === 0,
|
||||
[categoriesLoading, categoriesData?.categories?.length]
|
||||
)
|
||||
const showEmptyTemplates = useMemo(() =>
|
||||
!categoriesLoading && !templatesLoading && !showEmptyState && filteredTemplates.length === 0,
|
||||
[categoriesLoading, templatesLoading, showEmptyState, filteredTemplates.length]
|
||||
)
|
||||
|
||||
// 导航处理
|
||||
const handlePointsPress = () => router.push('/membership' as any)
|
||||
const handleSearchPress = () => router.push('/searchTemplate')
|
||||
const handleActivityPress = (link: string) => router.push(link as any)
|
||||
const handleArrowPress = () => router.push({
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// 下拉刷新处理
|
||||
const handleRefresh = useCallback(async () => {
|
||||
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,
|
||||
params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined,
|
||||
})
|
||||
const handleTemplatePress = (id: string) => router.push({
|
||||
}), [router, selectedCategoryId])
|
||||
const handleTemplatePress = useCallback((id: string) => router.push({
|
||||
pathname: '/templateDetail' as any,
|
||||
params: { id },
|
||||
})
|
||||
}), [router])
|
||||
|
||||
// 渲染模板卡片
|
||||
const renderTemplateItem = useCallback(({ item }: { item: typeof filteredTemplates[0] }) => {
|
||||
if (!item.id) return null
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
|
||||
{/* 标题栏 */}
|
||||
<TitleBar
|
||||
onPointsPress={handlePointsPress}
|
||||
onSearchPress={handleSearchPress}
|
||||
onLayout={handleTitleBarLayout}
|
||||
<TemplateCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
previewUrl={item.previewUrl}
|
||||
webpPreviewUrl={item.webpPreviewUrl}
|
||||
coverImageUrl={item.coverImageUrl}
|
||||
aspectRatio={item.aspectRatio}
|
||||
cardWidth={CARD_WIDTH}
|
||||
onPress={handleTemplatePress}
|
||||
/>
|
||||
)
|
||||
}, [handleTemplatePress])
|
||||
|
||||
{/* 吸顶标签导航 */}
|
||||
{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>
|
||||
)}
|
||||
// 提取 key
|
||||
const keyExtractor = useCallback((item: typeof filteredTemplates[0]) => item.id || '', [])
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
|
||||
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
|
||||
>
|
||||
// 列表头部组件
|
||||
const ListHeaderComponent = useMemo(() => (
|
||||
<>
|
||||
{/* 活动轮播图 */}
|
||||
<HeroSlider
|
||||
activities={activatesData?.activities || []}
|
||||
@@ -148,7 +195,7 @@ export default function HomeScreen() {
|
||||
|
||||
{/* 错误状态 */}
|
||||
{categoriesError && (
|
||||
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} />
|
||||
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} variant="error" />
|
||||
)}
|
||||
|
||||
{/* 空状态 - 分类数据为空 */}
|
||||
@@ -158,17 +205,118 @@ export default function HomeScreen() {
|
||||
|
||||
{/* 空状态 - 当前分类下没有模板 */}
|
||||
{showEmptyTemplates && (
|
||||
<ErrorState message="该分类暂无模板" onRetry={() => loadCategories()} />
|
||||
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
|
||||
)}
|
||||
|
||||
{/* 模板网格 */}
|
||||
{!showLoading && !showEmptyState && !showEmptyTemplates && (
|
||||
<TemplateGrid
|
||||
templates={filteredTemplates}
|
||||
onTemplatePress={handleTemplatePress}
|
||||
/>
|
||||
{/* 模板加载中 */}
|
||||
{templatesLoading && !showLoading && (
|
||||
<View style={styles.templatesLoadingContainer}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -183,9 +331,14 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
paddingBottom: 20,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
columnWrapper: {
|
||||
gap: CARD_GAP,
|
||||
marginBottom: CARD_GAP,
|
||||
},
|
||||
stickyTabsWrapper: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -194,4 +347,14 @@ const styles = StyleSheet.create({
|
||||
zIndex: 100,
|
||||
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 { 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'
|
||||
|
||||
interface ErrorStateProps extends ViewProps {
|
||||
message?: string
|
||||
onRetry?: () => void
|
||||
/** 是否显示为空状态图标(默认)或错误图标 */
|
||||
variant?: 'empty' | 'error'
|
||||
}
|
||||
|
||||
export default function ErrorState({ message = 'An error occurred', onRetry, testID, ...props }: ErrorStateProps) {
|
||||
/** 空状态图标 - 文件夹 */
|
||||
function EmptyIcon() {
|
||||
return (
|
||||
<View testID={testID} className="flex-1 items-center justify-center px-4" {...props}>
|
||||
<Text className="text-center text-gray-600 dark:text-gray-400">{message}</Text>
|
||||
<Svg width={80} height={80} viewBox="0 0 80 80" fill="none">
|
||||
<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 && (
|
||||
<TouchableOpacity onPress={onRetry} className="mt-4 px-6 py-2 bg-blue-500 rounded-lg">
|
||||
<Text className="text-white">Retry</Text>
|
||||
</TouchableOpacity>
|
||||
<Pressable onPress={onRetry} style={styles.retryButton}>
|
||||
<LinearGradient
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 { Image } from 'expo-image'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
@@ -41,6 +41,11 @@ export function getImageUri(
|
||||
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> = ({
|
||||
id,
|
||||
title,
|
||||
@@ -52,8 +57,26 @@ const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
||||
onPress,
|
||||
testID,
|
||||
}) => {
|
||||
const aspectRatio = parseAspectRatio(aspectRatioString)
|
||||
const imageUri = getImageUri(webpPreviewUrl, previewUrl, coverImageUrl)
|
||||
const aspectRatio = useMemo(() => parseAspectRatio(aspectRatioString), [aspectRatioString])
|
||||
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,则不渲染卡片
|
||||
if (!id) {
|
||||
@@ -62,28 +85,23 @@ const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.card, { width: cardWidth }]}
|
||||
onPress={() => onPress(id)}
|
||||
style={cardStyle}
|
||||
onPress={handlePress}
|
||||
testID={testID}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.cardImageContainer,
|
||||
aspectRatio ? { aspectRatio } : undefined,
|
||||
].filter(Boolean) as ViewStyle[]}
|
||||
>
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={[
|
||||
styles.cardImage,
|
||||
aspectRatio ? { aspectRatio } : undefined,
|
||||
].filter(Boolean) as ViewStyle[]}
|
||||
style={imageStyle}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
recyclingKey={id}
|
||||
transition={200}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={GRADIENT_COLORS}
|
||||
start={GRADIENT_START}
|
||||
end={GRADIENT_END}
|
||||
style={styles.cardImageGradient}
|
||||
/>
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, memo } from 'react'
|
||||
import { View, StyleSheet, LayoutChangeEvent } from 'react-native'
|
||||
import type { CategoryTemplate } from '@repo/sdk'
|
||||
import type { CategoryTemplate, TemplateDetail } from '@repo/sdk'
|
||||
import { TemplateCard } from './TemplateCard'
|
||||
|
||||
export type Template = CategoryTemplate
|
||||
// 支持 CategoryTemplate 和 TemplateDetail 两种类型
|
||||
export type Template = CategoryTemplate | TemplateDetail
|
||||
|
||||
export interface TemplateGridProps {
|
||||
templates: CategoryTemplate[]
|
||||
templates: Template[]
|
||||
onTemplatePress: (id: string) => void
|
||||
numColumns?: number
|
||||
horizontalPadding?: number
|
||||
@@ -40,7 +41,7 @@ const TemplateGridComponent: React.FC<TemplateGridProps> = ({
|
||||
const [gridWidth, setGridWidth] = useState(0)
|
||||
|
||||
// 过滤掉没有 id 的模板
|
||||
const validTemplates = templates.filter((template): template is CategoryTemplate & { id: string } =>
|
||||
const validTemplates = templates.filter((template): template is Template & { id: string } =>
|
||||
!!template.id
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { type ApiError } from '@/lib/types'
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
import { handleError } from './use-error'
|
||||
|
||||
type ListCategoryTemplatesParams = Omit<ListTemplatesInput, 'ownerId'>
|
||||
type ListCategoryTemplatesParams = Partial<Omit<ListTemplatesInput, 'ownerId'>>
|
||||
|
||||
interface UseCategoryTemplatesOptions {
|
||||
categoryId?: string
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
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
|
||||
*/
|
||||
export interface UseTemplateFilterOptions {
|
||||
templates: CategoryTemplate[]
|
||||
export interface UseTemplateFilterOptions<T extends FilterableTemplate = FilterableTemplate> {
|
||||
templates: T[]
|
||||
excludeVideo?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useTemplateFilter hook
|
||||
*/
|
||||
export interface UseTemplateFilterReturn {
|
||||
filteredTemplates: CategoryTemplate[]
|
||||
export interface UseTemplateFilterReturn<T extends FilterableTemplate = FilterableTemplate> {
|
||||
filteredTemplates: T[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +34,7 @@ const VIDEO_EXTENSIONS = ['mp4', 'mov', 'webm']
|
||||
* @param options.excludeVideo - Whether to exclude video templates (default: false)
|
||||
* @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 filteredTemplates = useMemo(() => {
|
||||
|
||||
Reference in New Issue
Block a user