expo-duooomi-app/app/(tabs)/index.tsx

700 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons'
import { Block, ConfirmModal, ListEmpty, Text, Toast, VideoBox } from '@share/components'
import { FlashList } from '@shopify/flash-list'
import { LinearGradient } from 'expo-linear-gradient'
import { router, useFocusEffect } from 'expo-router'
import { observer } from 'mobx-react-lite'
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActivityIndicator, RefreshControl, TextInput } from 'react-native'
import BannerSection from '@/components/BannerSection'
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
import { useTemplates } from '@/hooks/data'
import { useFavoriteTemplates } from '@/hooks/data/use-favorite-templates'
import { userBalanceStore, userStore } from '@/stores'
import { screenWidth } from '@/utils'
import { cn } from '@/utils/cn'
const CATEGORY_ID = process.env.EXPO_PUBLIC_INDEX_GROUP_ID
const ITEM_WIDTH = Math.floor((screenWidth - 24 - 12 * 2) / 3)
const PAGE_SIZE = 21
type MediaItem = {
id: string
type: 'image' | 'video'
url: string
poster?: string
likeCount?: number
price: number
title?: string
authorName?: string
}
type ActiveTab = 'gen' | '' | 'new' | 'like'
/** =========================
* Entry page
* ========================= */
const Index = observer(function Index() {
// 从MobX Store获取用户信息
const { user, isAuthenticated } = userStore as typeof userStore
const { execute: loadTemplates } = useTemplates()
const { execute: loadFavorites } = useFavoriteTemplates()
const { runTemplate } = useTemplateActions()
// 使用MobX Store中的余额信息
const { balance } = userBalanceStore
/** ================= 状态 ================= */
const [activeTab, setActiveTab] = useState<ActiveTab>('')
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [allItems, setAllItems] = useState<MediaItem[]>([])
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
/** ================= refs核心 ================= */
const queryRef = useRef({
page: 1,
pageSize: PAGE_SIZE,
search: '',
tab: '' as ActiveTab,
})
const loadingRef = useRef(false)
const firstLoadDoneRef = useRef(false)
/** ================= 工具函数 ================= */
type TemplateData = {
id: string
previewUrl?: string
coverImageUrl?: string
likeCount?: number
price?: number
title?: string
isDeleted?: boolean
}
const transformTemplateToMediaItem = useCallback((template: TemplateData): MediaItem => {
const isVideo = template.previewUrl?.includes('.mp4')
return {
id: template.id,
type: isVideo ? 'video' : 'image',
url: isVideo ? (template.previewUrl ?? '') : template.coverImageUrl || template.previewUrl || '',
poster: isVideo ? template.coverImageUrl : undefined,
likeCount: template.likeCount || 0,
price: template.price || 0,
authorName: template?.user?.name || '未知作者',
title: template.title || '',
}
}, [])
/** ================= 统一请求函数 ================= */
const fetchList = async (mode: 'init' | 'refresh' | 'loadMore') => {
if (loadingRef.current) return
if (mode === 'loadMore' && !hasMore) return
loadingRef.current = true
if (mode === 'loadMore') {
setLoadingMore(true)
} else {
setRefreshing(true)
}
try {
const { page, pageSize, search, tab } = queryRef.current
let newItems: MediaItem[] = []
if (tab === 'like') {
if (!isAuthenticated) {
setHasMore(false)
newItems = []
} else {
const { data } = await loadFavorites({ page, limit: pageSize })
newItems =
data?.favorites
?.filter((f) => f.template && !(f.template as TemplateData).isDeleted)
.map((f) => transformTemplateToMediaItem(f.template as TemplateData)) || []
}
} else {
const sortBy = tab === 'new' ? 'createdAt' : 'likeCount'
const { data } = await loadTemplates({
page,
limit: pageSize,
sortBy,
sortOrder: 'desc',
search,
categoryId: CATEGORY_ID,
})
newItems = data?.templates?.map(transformTemplateToMediaItem) || []
}
if (newItems.length < pageSize) {
setHasMore(false)
}
const updatedItems = mode === 'loadMore' ? [...allItems, ...newItems] : newItems
setAllItems(updatedItems)
} catch (e) {
console.error('fetchList error:', e)
setHasMore(false)
} finally {
loadingRef.current = false
// 标记首次(或第一页)加载已完成,避免 FlashList 首次渲染触发 onEndReached
const currentPage = queryRef.current.page
if (currentPage === 1) {
firstLoadDoneRef.current = true
}
setRefreshing(false)
setLoadingMore(false)
}
}
/** ================= tab 切换(抽象后的重点) ================= */
const changeTab = useCallback((tab: ActiveTab) => {
queryRef.current = {
...queryRef.current,
tab,
page: 1,
}
setHasMore(true)
firstLoadDoneRef.current = false
fetchList('init')
}, [])
/** ================= 搜索 ================= */
const handleSearch = useCallback((text: string) => {
queryRef.current = {
...queryRef.current,
search: text,
page: 1,
}
setHasMore(true)
firstLoadDoneRef.current = false
fetchList('refresh')
}, [])
/** ================= 刷新 / 加载更多 ================= */
const handleRefresh = useCallback(() => {
queryRef.current.page = 1
setHasMore(true)
firstLoadDoneRef.current = false
fetchList('refresh')
}, [])
const handleLoadMore = useCallback(() => {
// 防止重复请求或首次渲染时误触发
if (loadingRef.current || !hasMore || refreshing || loadingMore) return
if (!firstLoadDoneRef.current) return
// if (allItems.length === 0) return // 首次加载未完成时不触发加载更多
queryRef.current.page += 1
fetchList('loadMore')
console.log('handleLoadMore-------------')
}, [hasMore, refreshing, loadingMore])
/** ================= selectedItem 修正 ================= */
useEffect(() => {
if (!allItems.length) {
setSelectedItem(null)
return
}
if (!allItems.find((i) => i.id === selectedItem?.id)) {
setSelectedItem(allItems[0])
}
}, [allItems, selectedItem?.id])
useEffect(() => {
fetchList('init')
// 初次加载时直接加载余额
userBalanceStore.load(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 管理轮询:用户登录时启动,登出时停止,使用延迟判断避免刷新时的临时状态
useEffect(() => {
// 使用短暂延迟,避免刷新时的临时状态变化
const timer = setTimeout(() => {
if (isAuthenticated && user?.id) {
console.log('用户已登录,启动余额轮询')
userBalanceStore.startPolling()
} else {
console.log('用户未登录,停止余额轮询')
userBalanceStore.stopPolling()
}
}, 100) // 100ms延迟避免刷新时的快速状态切换
return () => {
clearTimeout(timer)
}
}, [isAuthenticated, user?.id])
// 组件卸载时确保轮询停止
useEffect(() => {
return () => {
userBalanceStore.stopPolling()
}
}, [])
useFocusEffect(() => {
if (isAuthenticated && user?.id) {
userBalanceStore.load(true)
}
})
/** ================= 快速生成 ================= */
const handleQuickGen = async () => {
if (!isAuthenticated) {
router.push('/auth')
return
}
if (!selectedItem) {
Toast.show({ title: '请先选择一个模板' })
return
}
// 显示加载状态并刷新余额
Toast.showLoading()
try {
await userBalanceStore.load(true) // 生成前刷新余额
// 使用最新的余额数据进行检查
const currentBalance = userBalanceStore.balance
if (currentBalance < selectedItem.price) {
Toast.show({ title: '余额不足,请充值' })
return
}
} catch (error) {
Toast.show({ title: '余额加载失败,请重试' })
return
} finally {
Toast.hideLoading()
}
Toast.showModal(
<ConfirmModal
content={
<Text className="text-[14px] font-bold">
<Text className="mx-[4px] text-[20px] text-[#e61e25]">{selectedItem.price}</Text>
</Text>
}
onCancel={Toast.hideModal}
onConfirm={async () => {
Toast.hideModal()
Toast.showLoading()
try {
// 先进行乐观更新,扣减余额
userBalanceStore.deductBalance(selectedItem.price)
const { generationId, error } = await runTemplate({
templateId: selectedItem.id,
data: {},
})
if (generationId && user?.id) {
// 生成成功后强制刷新余额以获取准确数据
await userBalanceStore.load(true)
Toast.show({ title: '生成任务开启,请在我的生成中查看' })
} else {
// 生成失败,恢复余额
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
Toast.show({ title: error?.message || '生成失败' })
}
} catch (error) {
// 异常情况下恢复余额
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
Toast.show({ title: '网络异常,请重试' })
} finally {
Toast.hideLoading()
}
}}
/>,
)
}
const renderListEmpty = () => {
if (activeTab === 'like' && !isAuthenticated) {
return (
<Block className="mt-[40px] items-center justify-center gap-[16px] py-[60px]">
<Block className="size-[80px] items-center justify-center rounded-full border-4 border-white/20 bg-white/10">
<Ionicons color="rgba(255,255,255,0.6)" name="heart-outline" size={40} />
</Block>
<Text className="text-[16px] font-bold text-white/80"></Text>
<Text className="text-[12px] text-white/50"></Text>
<Block
className="mt-[12px] flex-row items-center gap-[8px] border-[3px] border-white bg-accent px-[32px] py-[12px] shadow-[4px_4px_0px_rgba(255,255,255,0.2)]"
style={{ transform: [{ skewX: '-6deg' }] }}
onClick={() => router.push('/auth')}
>
<Ionicons color="black" name="flash" size={16} style={{ transform: [{ skewX: '6deg' }] }} />
<Text className="font-900 text-[14px] text-black" style={{ transform: [{ skewX: '6deg' }] }}>
</Text>
</Block>
</Block>
)
} else {
return <ListEmpty />
}
}
// 缓存渲染函数以提升性能
const renderItem = useCallback(
({ item }: { item: MediaItem }) => (
<GridItem
isSelected={selectedItem?.id === item.id}
item={item}
itemWidth={ITEM_WIDTH}
onSelect={() => setSelectedItem(item)}
/>
),
[selectedItem?.id],
)
const keyExtractor = useCallback((item: MediaItem) => item.id, [])
/** ================= UI ================= */
return (
<Block className="flex-1 bg-black px-[12px]">
<BannerSection />
<HeroCircle onOpenSearch={() => setIsSearchOpen(true)} onQuickGen={handleQuickGen} selectedItem={selectedItem} />
<FilterSection
activeTab={activeTab}
onChange={(tab) => {
setActiveTab(tab)
changeTab(tab)
}}
/>
<FlashList
contentContainerStyle={{ paddingBottom: 200, marginTop: 12 }}
ListFooterComponent={
loadingMore ? (
<Block className="items-center py-[20px]">
<ActivityIndicator color="#FFE500" />
</Block>
) : null
}
numColumns={3}
onEndReached={handleLoadMore}
drawDistance={1200}
onEndReachedThreshold={0.3}
refreshControl={<RefreshControl colors={['#FFE500']} refreshing={refreshing} onRefresh={handleRefresh} />}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListEmptyComponent={renderListEmpty}
showsVerticalScrollIndicator={false}
data={allItems}
// @ts-ignore
estimatedItemSize={ITEM_WIDTH}
/>
<SearchOverlay isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} onSearch={handleSearch} />
</Block>
)
})
type SearchOverlayProps = {
isOpen: boolean
searchText?: string
onChange?: (v: string) => void
onClose: () => void
onSearch: (v: string) => void
}
const SearchOverlay = memo<SearchOverlayProps>(function SearchOverlay({ isOpen, onChange, onClose, onSearch }) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [searchText, setSearchText] = useState('')
const handleTextChange = useCallback(
(text: string) => {
setSearchText(text)
onChange?.(text)
if (timerRef.current) {
clearTimeout(timerRef.current)
}
// 增加防抖时间到500ms减少频繁搜索
timerRef.current = setTimeout(() => {
onSearch(text)
}, 500)
},
[onChange, onSearch],
)
const handleClose = useCallback(() => {
setSearchText('')
onSearch('')
onClose?.()
}, [onClose, onSearch])
if (!isOpen) return null
return (
<Block className="absolute inset-x-0 top-0 z-50 mt-[24px] flex-row items-center gap-[8px] px-[20px]">
<Block
className="flex-1 flex-row items-center border-[3px] border-black bg-white px-[12px] shadow-[4px_4px_0px_#000]"
style={{ height: 48, transform: [{ skewX: '-6deg' }] }}
>
<Ionicons color="black" name="search" size={20} style={{ marginRight: 8 }} />
<TextInput
autoFocus
onChangeText={handleTextChange}
placeholder="搜索作品 / 用户..."
placeholderTextColor="#9CA3AF"
style={{
flex: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'black',
}}
value={searchText}
/>
</Block>
<Block
className="items-center justify-center border-[3px] border-black bg-accent shadow-[4px_4px_0px_#000]"
onClick={handleClose}
style={{ width: 48, height: 48, transform: [{ skewX: '-6deg' }] }}
>
<Ionicons color="black" name="close" size={24} style={{ transform: [{ skewX: '6deg' }] }} />
</Block>
</Block>
)
})
type GooActionsProps = {
gooPoints: number
onAddGoo: () => void
onOpenSearch: () => void
}
const GooActions = observer<GooActionsProps>(function GooActions({ gooPoints, onAddGoo, onOpenSearch }) {
const { isPolling } = userBalanceStore
const isDev = __DEV__
const isAuthenticated = userStore.isAuthenticated
return (
<Block>
<Block className="mt-[12px] flex-row items-center gap-[8px]">
{!!isAuthenticated && (
<Block
className="flex-row items-center gap-[4px] rounded-full border-[3px] border-white bg-black px-[12px] py-[8px] shadow-[3px_3px_0px_rgba(0,0,0,0.5)]"
onClick={onAddGoo}
>
<Text className="text-[12px] font-black text-accent">{gooPoints}</Text>
<Ionicons color="#FFE500" name="add" size={12} style={{ fontWeight: 'bold' }} />
{/* 开发模式下显示轮询状态 */}
{isDev && isPolling && <Block className="ml-[4px] size-[6px] rounded-full bg-green-500" />}
</Block>
)}
<Block
className="size-[48px] items-center justify-center rounded-full border-[3px] border-black bg-white shadow-[4px_4px_0px_#000]"
onClick={onOpenSearch}
>
<Ionicons color="black" name="search" size={22} />
</Block>
</Block>
</Block>
)
})
type HeroCircleProps = {
selectedItem: MediaItem | null
onQuickGen: () => void
onOpenSearch: () => void
}
const HeroCircle = observer<HeroCircleProps>(function HeroCircle({ selectedItem, onQuickGen, onOpenSearch }) {
const isAuthenticated = userStore.isAuthenticated
const { balance } = userBalanceStore
const existItem = !!selectedItem?.url
return (
<Block className="relative z-10 items-center">
<Block className="relative mx-[12px] flex-row justify-between">
<Block className="flex-1">
<Block className="relative z-10 mt-[20px] flex size-[224px] rounded-full border-4 border-black shadow-deep-black">
{existItem && (
<Block className="relative size-full overflow-hidden rounded-full">
<VideoBox style={{ height: 216, width: 216, borderRadius: 108 }} url={selectedItem.url} />
</Block>
)}
{!existItem && (
<Block className="relative size-full overflow-hidden rounded-full bg-transparent">
<Text className="text-[14px] font-bold text-white/60">...</Text>
</Block>
)}
<Block className="pointer-events-none absolute inset-0 rounded-full border-2 border-black/10" />
<Block className="absolute bottom-[24px] left-[8px] z-20 max-w-[100px] bg-black px-[12px] py-[4px] shadow-small-accent">
<Text className="font-900 -skew-x-12 text-[10px] text-white" ellipsizeMode="tail" numberOfLines={1}>
{selectedItem?.title}
</Text>
</Block>
<Block
className="absolute bottom-[16px] right-0 z-30 -skew-x-6 flex-row items-center gap-[4px] border-[3px] border-black bg-accent px-[16px] py-[6px] shadow-medium-black"
onClick={onQuickGen}
>
<Ionicons color="black" name="flash" size={14} />
<Text className="font-900 text-[12px] text-black">GET </Text>
</Block>
</Block>
</Block>
<GooActions
gooPoints={balance}
onAddGoo={() => {
if (!isAuthenticated) {
router.push('/auth')
} else {
router.push('/pointList')
}
}}
onOpenSearch={onOpenSearch}
/>
</Block>
</Block>
)
})
type FilterSectionProps = {
activeTab: ActiveTab
onChange: (t: ActiveTab) => void
}
const FilterSection = memo<FilterSectionProps>(function FilterSection({ activeTab, onChange }) {
const tabs = useMemo(
() => [
{ label: '最热', state: '' as const },
{ label: '最新', state: 'new' as const },
{ label: '喜欢', state: 'like' as const },
],
[],
)
return (
<Block className="mt-[12px] flex-row items-end justify-between">
<Block className="flex-row gap-[8px]">
{tabs.map(({ label, state }) => {
const isActive = activeTab === state
return (
<Block
className={`border-2 border-black px-[16px] py-[4px] ${isActive ? 'bg-accent' : 'bg-white'}`}
key={state}
onClick={() => onChange(state)}
style={{
transform: [{ skewX: '-6deg' }],
}}
>
<Text className={`text-[10px] font-[900] ${isActive ? 'text-black' : 'text-gray-500'}`}>{label}</Text>
</Block>
)
})}
</Block>
</Block>
)
})
type GridItemProps = {
item: MediaItem
isSelected: boolean
itemWidth: number
onSelect: () => void
}
// 优化GridItem组件减少不必要的重渲染
const GridItem = memo<GridItemProps>(
function GridItem({ item, isSelected, itemWidth, onSelect }) {
// console.log('item-------------', item);
return (
<Block className="relative mb-[12px]" onClick={onSelect}>
<Block
className={`relative overflow-hidden border-2 ${isSelected ? 'border-accent' : 'border-black'}`}
style={{
transform: [{ skewX: '-6deg' }],
height: itemWidth,
width: itemWidth,
}}
>
<VideoBox style={{ height: itemWidth, width: itemWidth }} url={item.url} />
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
<Block
className={cn(
'absolute left-[0px] top-[0px] z-[20] border-b-[2px] border-r-[2px] border-black bg-black px-[6px] py-[2px]',
{
'bg-accent': isSelected,
},
)}
>
<Text
className={cn('font-900 text-[8px] text-white', {
'text-black': isSelected,
})}
>
{item.title}
</Text>
</Block>
<Block
className="absolute inset-x-0 bottom-0 z-20 items-end justify-between p-[4px] pt-[16px]"
style={{ transform: [{ skewX: '6deg' }] }}
>
<LinearGradient
colors={['rgba(0,0,0,0.8)', 'transparent']}
end={{ x: 0, y: 0 }}
start={{ x: 0, y: 1 }}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
/>
<Block className="w-full flex-1 flex-row items-center justify-between px-[4px]">
<Block className="">
<Text className="font-900 max-w-[40px] -skew-x-6 text-[7px] text-white" numberOfLines={1}>
{item.authorName || '未知作者'}
</Text>
</Block>
<Block className="-skew-x-6 flex-row items-center gap-[2px]">
<Ionicons color="#FF0000" name="heart" size={10} />
<Text className="font-900 text-[8px] text-white">{item.likeCount}</Text>
</Block>
</Block>
</Block>
</Block>
</Block>
)
},
(prevProps, nextProps) => {
// 自定义比较函数,只在关键属性变化时才重渲染
return (
prevProps.item.id === nextProps.item.id &&
prevProps.isSelected === nextProps.isSelected &&
prevProps.itemWidth === nextProps.itemWidth &&
prevProps.item.likeCount === nextProps.item.likeCount
)
},
)
export default Index