456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||
import { StatusBar } from 'expo-status-bar'
|
||
import React, { useEffect, useCallback, useState, useMemo } from 'react'
|
||
import {
|
||
Platform,
|
||
FlatList,
|
||
StyleSheet,
|
||
StatusBar as RNStatusBar,
|
||
View,
|
||
ActivityIndicator,
|
||
RefreshControl,
|
||
Dimensions,
|
||
} from 'react-native'
|
||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||
|
||
import { TitleBar, HeroSlider, TabNavigation, TemplateCard, TemplateGrid } from '@/components/blocks/home'
|
||
import ErrorState from '@/components/ErrorState'
|
||
import LoadingState from '@/components/LoadingState'
|
||
import { useActivates } from '@/hooks/use-activates'
|
||
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'
|
||
import { useUserBalance } from '@/hooks/use-user-balance'
|
||
import { useTemplateLike, useTemplateFavorite } from '@/hooks'
|
||
import { useTemplateSocialStore } from '@/stores/templateSocialStore'
|
||
import { root } from '@repo/core'
|
||
import { TemplateSocialController } from '@repo/sdk'
|
||
import { handleError } from '@/hooks/use-error'
|
||
|
||
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 { balance } = useUserBalance()
|
||
|
||
// 分类数据加载
|
||
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,
|
||
tabs,
|
||
selectTab,
|
||
selectCategoryById,
|
||
} = useTabNavigation({
|
||
categories: categoriesData?.categories || [],
|
||
initialCategoryId: params.categoryId as string | undefined,
|
||
})
|
||
|
||
// 吸顶状态
|
||
const {
|
||
isSticky,
|
||
tabsHeight,
|
||
titleBarHeightRef,
|
||
handleScroll,
|
||
handleTabsLayout,
|
||
handleTitleBarLayout,
|
||
} = useStickyTabs()
|
||
|
||
// 使用 Store 中的点赞/收藏状态
|
||
const { setLiked, setFavorited, incrementLikeCount, decrementLikeCount, setLikeCountStates } = useTemplateSocialStore()
|
||
// 模板过滤 - 使用 useMemo 缓存
|
||
const { filteredTemplates } = useTemplateFilter({
|
||
templates,
|
||
excludeVideo: true,
|
||
})
|
||
|
||
// 初始化加载
|
||
useEffect(() => {
|
||
loadCategories()
|
||
loadActivates()
|
||
}, [])
|
||
|
||
// 当分类变化时加载模板
|
||
useEffect(() => {
|
||
if (selectedCategoryId) {
|
||
loadTemplates({ categoryId: selectedCategoryId })
|
||
}
|
||
}, [selectedCategoryId])
|
||
|
||
// 路由参数同步
|
||
useEffect(() => {
|
||
if (params.categoryId) {
|
||
selectCategoryById(params.categoryId as string)
|
||
}
|
||
}, [params.categoryId])
|
||
|
||
// 同步模板的点赞数量到 store(用于初始化 store 中的数据)
|
||
useEffect(() => {
|
||
if (filteredTemplates.length > 0) {
|
||
const likeCountMap: Record<string, number> = {}
|
||
filteredTemplates.forEach(template => {
|
||
if (template.id && 'likeCount' in template && template.likeCount !== undefined) {
|
||
likeCountMap[template.id] = template.likeCount
|
||
}
|
||
})
|
||
if (Object.keys(likeCountMap).length > 0) {
|
||
setLikeCountStates(likeCountMap)
|
||
}
|
||
}
|
||
}, [filteredTemplates, setLikeCountStates])
|
||
|
||
// 状态判断 - 使用 useMemo 缓存
|
||
// 统一 loading 状态:只要有任何一个在加载,就显示 loading
|
||
const isLoading = useMemo(() =>
|
||
categoriesLoading || templatesLoading,
|
||
[categoriesLoading, templatesLoading]
|
||
)
|
||
const showEmptyState = useMemo(() =>
|
||
!isLoading && categoriesData?.categories?.length === 0,
|
||
[isLoading, categoriesData?.categories?.length]
|
||
)
|
||
const showEmptyTemplates = useMemo(() =>
|
||
!isLoading && !showEmptyState && filteredTemplates.length === 0,
|
||
[isLoading, showEmptyState, filteredTemplates.length]
|
||
)
|
||
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
// 下拉刷新处理 - 只刷新当前分类的模板
|
||
const handleRefresh = useCallback(async () => {
|
||
if (!selectedCategoryId) return
|
||
setRefreshing(true)
|
||
await loadTemplates({ categoryId: selectedCategoryId })
|
||
setRefreshing(false)
|
||
}, [selectedCategoryId, loadTemplates])
|
||
|
||
// 加载更多处理
|
||
const handleEndReached = useCallback(() => {
|
||
if (!loadingMore && hasMore && !isLoading) {
|
||
loadMore()
|
||
}
|
||
}, [loadingMore, hasMore, isLoading, 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,
|
||
}), [router, selectedCategoryId])
|
||
const handleTemplatePress = useCallback((id: string) => router.push({
|
||
pathname: '/templateDetail' as any,
|
||
params: { id },
|
||
}), [router])
|
||
|
||
|
||
// 获取 social controller
|
||
const getSocialController = useCallback(() => {
|
||
return root.get(TemplateSocialController)
|
||
}, [])
|
||
|
||
// 点赞处理
|
||
const handleLike = useCallback(async (id: string) => {
|
||
setLiked(id, true) // 乐观更新状态
|
||
incrementLikeCount(id) // 乐观更新数量
|
||
try {
|
||
const social = getSocialController()
|
||
await handleError(() => social.like({ templateId: id }))
|
||
console.log('Liked template:', id)
|
||
} catch (e) {
|
||
// 失败时回滚状态
|
||
setLiked(id, false)
|
||
decrementLikeCount(id)
|
||
console.error('Like failed:', e)
|
||
}
|
||
}, [setLiked, incrementLikeCount, decrementLikeCount, getSocialController])
|
||
|
||
// 取消点赞处理
|
||
const handleUnlike = useCallback(async (id: string) => {
|
||
setLiked(id, false) // 乐观更新状态
|
||
decrementLikeCount(id) // 乐观更新数量
|
||
try {
|
||
const social = getSocialController()
|
||
await handleError(() => social.unlike({ templateId: id }))
|
||
console.log('Unliked template:', id)
|
||
} catch (e) {
|
||
// 失败时回滚状态
|
||
setLiked(id, true)
|
||
incrementLikeCount(id)
|
||
console.error('Unlike failed:', e)
|
||
}
|
||
}, [setLiked, incrementLikeCount, decrementLikeCount, getSocialController])
|
||
|
||
// 收藏处理
|
||
const handleFavorite = useCallback(async (id: string) => {
|
||
setFavorited(id, true) // 乐观更新
|
||
try {
|
||
const social = getSocialController()
|
||
await handleError(() => social.favorite({ templateId: id }))
|
||
console.log('Favorited template:', id)
|
||
} catch (e) {
|
||
// 失败时回滚状态
|
||
setFavorited(id, false)
|
||
console.error('Favorite failed:', e)
|
||
}
|
||
}, [setFavorited, getSocialController])
|
||
|
||
// 取消收藏处理
|
||
const handleUnfavorite = useCallback(async (id: string) => {
|
||
setFavorited(id, false) // 乐观更新
|
||
try {
|
||
const social = getSocialController()
|
||
await handleError(() => social.unfavorite({ templateId: id }))
|
||
console.log('Unfavorited template:', id)
|
||
} catch (e) {
|
||
// 失败时回滚状态
|
||
setFavorited(id, true)
|
||
console.error('Unfavorite failed:', e)
|
||
}
|
||
}, [setFavorited, getSocialController])
|
||
|
||
// 渲染模板卡片
|
||
const renderTemplateItem = useCallback(({ item }: { item: typeof filteredTemplates[0] }) => {
|
||
if (!item.id) return null
|
||
return (
|
||
<TemplateCard
|
||
id={item.id}
|
||
title={item.title}
|
||
titleEn={item.titleEn}
|
||
previewUrl={item.previewUrl}
|
||
webpPreviewUrl={item.webpPreviewUrl}
|
||
coverImageUrl={item.coverImageUrl}
|
||
aspectRatio={item.aspectRatio}
|
||
cardWidth={CARD_WIDTH}
|
||
onPress={handleTemplatePress}
|
||
liked={'isLiked' in item ? item.isLiked : undefined}
|
||
favorited={'isFavorited' in item ? item.isFavorited : undefined}
|
||
likeCount={'likeCount' in item ? item.likeCount : undefined}
|
||
onLike={handleLike}
|
||
onUnlike={handleUnlike}
|
||
onFavorite={handleFavorite}
|
||
onUnfavorite={handleUnfavorite}
|
||
/>
|
||
)
|
||
}, [handleTemplatePress, handleLike, handleUnlike, handleFavorite, handleUnfavorite])
|
||
|
||
// 提取 key
|
||
const keyExtractor = useCallback((item: typeof filteredTemplates[0]) => item.id || '', [])
|
||
|
||
// 列表头部组件
|
||
const ListHeaderComponent = useMemo(() => (
|
||
<>
|
||
{/* 活动轮播图 */}
|
||
<HeroSlider
|
||
activities={activatesData?.activities || []}
|
||
onActivityPress={handleActivityPress}
|
||
/>
|
||
|
||
{/* 标签导航 */}
|
||
{!isLoading && !showEmptyState && (
|
||
<View
|
||
onLayout={(e) => handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
|
||
style={isSticky ? { opacity: 0, height: tabsHeight } : undefined}
|
||
>
|
||
<TabNavigation
|
||
tabs={tabs}
|
||
activeIndex={activeIndex}
|
||
onTabPress={selectTab}
|
||
showArrow={true}
|
||
onArrowPress={handleArrowPress}
|
||
/>
|
||
</View>
|
||
)}
|
||
|
||
{/* 统一的加载状态 */}
|
||
{isLoading && <LoadingState />}
|
||
|
||
{/* 错误状态 */}
|
||
{categoriesError && (
|
||
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} variant="error" />
|
||
)}
|
||
|
||
{/* 空状态 - 分类数据为空 */}
|
||
{showEmptyState && !categoriesError && (
|
||
<ErrorState message="暂无分类数据" onRetry={() => loadCategories()} />
|
||
)}
|
||
|
||
{/* 空状态 - 当前分类下没有模板 */}
|
||
{showEmptyTemplates && (
|
||
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
|
||
)}
|
||
</>
|
||
), [
|
||
activatesData?.activities,
|
||
handleActivityPress,
|
||
isLoading,
|
||
showEmptyState,
|
||
isSticky,
|
||
tabsHeight,
|
||
tabs,
|
||
activeIndex,
|
||
selectTab,
|
||
handleArrowPress,
|
||
categoriesError,
|
||
showEmptyTemplates,
|
||
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 = !isLoading && !showEmptyState && !showEmptyTemplates
|
||
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['top']}>
|
||
<StatusBar style="light" />
|
||
<RNStatusBar barStyle="light-content" />
|
||
|
||
{/* 标题栏 */}
|
||
<TitleBar
|
||
points={balance}
|
||
onPointsPress={handlePointsPress}
|
||
onSearchPress={handleSearchPress}
|
||
onLayout={handleTitleBarLayout}
|
||
/>
|
||
|
||
{/* 吸顶标签导航 */}
|
||
{isSticky && !isLoading && !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, { paddingBottom: 60 + insets.bottom + 20 }]}
|
||
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>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
backgroundColor: '#090A0B',
|
||
},
|
||
scrollView: {
|
||
flex: 1,
|
||
backgroundColor: '#090A0B',
|
||
},
|
||
scrollContent: {
|
||
paddingHorizontal: HORIZONTAL_PADDING,
|
||
paddingBottom: 20,
|
||
backgroundColor: '#090A0B',
|
||
},
|
||
columnWrapper: {
|
||
gap: CARD_GAP,
|
||
marginBottom: CARD_GAP,
|
||
},
|
||
stickyTabsWrapper: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
zIndex: 100,
|
||
backgroundColor: '#090A0B',
|
||
},
|
||
templatesLoadingContainer: {
|
||
paddingVertical: 40,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
loadingMoreContainer: {
|
||
paddingVertical: 20,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
})
|