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

766 lines
24 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 { useIsFocused } from '@react-navigation/native'
import { Block, ConfirmModal, Img, ListEmpty, Text, Toast, VideoBox } from '@share/components'
import { FlashList } from '@shopify/flash-list'
import { Image } from 'expo-image'
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 = 12
type MediaItem = {
id: string
type: 'image' | 'video'
url: string | ''
poster?: string
webpPreviewUrl?: string
webpHighPreviewUrl?: string
mp4Url?: 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()
/** ================= 状态 ================= */
const [activeTab, setActiveTab] = useState<ActiveTab>('')
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [allItems, setAllItems] = useState<MediaItem[]>([])
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null)
// 直接使用 useIsFocused hook无需手动管理状态
const isFocused = useIsFocused()
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 || '',
webpPreviewUrl: template?.webpPreviewUrl,
webpHighPreviewUrl: template?.webpHighPreviewUrl,
mp4Url: 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') {
console.log('加载收藏列表isAuthenticated=', isAuthenticated)
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
const currentPage = queryRef.current.page
if (currentPage === 1) {
firstLoadDoneRef.current = true
}
setRefreshing(false)
setLoadingMore(false)
}
}
/** ================= tab 切换 ================= */
const changeTab = useCallback(
(tab: ActiveTab) => {
loadingRef.current = false
queryRef.current = {
...queryRef.current,
tab,
page: 1,
}
setHasMore(true)
firstLoadDoneRef.current = false
fetchList('init')
},
[isAuthenticated],
)
/** ================= 搜索 ================= */
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
queryRef.current.page += 1
fetchList('loadMore')
}, [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)
return () => {
clearTimeout(timer)
}
}, [isAuthenticated, user?.id])
// 页面失焦时清理内存缓存,减少内存占用
useEffect(() => {
if (!isFocused) {
// 清理内存缓存,保留磁盘缓存
Image.clearMemoryCache()
// console.log('页面失焦,清理内存缓存')
}
}, [isFocused])
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 />
}
}
// 简化的 selectedId避免对象引用变化
const selectedId = selectedItem?.id
// 可见性状态
const visibleIdsRef = useRef<Set<string>>(new Set())
const [visibilityVersion, setVisibilityVersion] = useState(0)
// 可见性变化回调 - 优化版:减少状态更新频率
const onViewableItemsChanged = useCallback(
(info: { viewableItems: { isViewable: boolean; key: string; index: number | null }[] }) => {
const { viewableItems } = info
const currentVisible = new Set<string>(viewableItems.filter((v) => v.isViewable).map((v) => v.key))
// 快速滑动时可能为空,保留上次状态
if (currentVisible.size === 0 && visibleIdsRef.current.size > 0) return
// 添加前后缓冲减少到3个item以降低内存占用
if (viewableItems.length > 0 && allItems.length > 0) {
const first = viewableItems[0]?.index ?? 0
const last = viewableItems[viewableItems.length - 1]?.index ?? first
if (first !== null && last !== null) {
// 每列3个所以前后各加3行9个item
for (let i = Math.max(0, first - 9); i < first; i++) {
if (allItems[i]) currentVisible.add(allItems[i].id)
}
for (let i = last + 1; i < Math.min(allItems.length, last + 10); i++) {
currentVisible.add(allItems[i].id)
}
}
}
// 只有可见项发生显著变化时才更新状态(避免频繁重渲染)
const hasSignificantChange =
Math.abs(currentVisible.size - visibleIdsRef.current.size) > 3 ||
[...currentVisible].some((id) => !visibleIdsRef.current.has(id))
if (hasSignificantChange) {
visibleIdsRef.current = currentVisible
setVisibilityVersion((v) => v + 1)
}
},
[allItems],
)
const renderItem = useCallback(
({ item, index }: { item: MediaItem; index: number }) => (
<GridItem
isSelected={selectedId === item.id}
item={item}
itemWidth={ITEM_WIDTH}
// 页面失焦时不渲染减少内存占用
isVisible={isFocused && (index < 9 || visibleIdsRef.current.has(item.id))}
onSelect={() => setSelectedItem(item)}
/>
),
[selectedId, isFocused],
)
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}
// 设置合理的回收池大小避免内存无限增长
maxItemsInRecyclePool={12}
removeClippedSubviews={true}
// 减小预渲染距离降低内存占用
drawDistance={150}
onEndReachedThreshold={0.3}
refreshControl={<RefreshControl colors={['#FFE500']} refreshing={refreshing} onRefresh={handleRefresh} />}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListEmptyComponent={renderListEmpty}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 10,
minimumViewTime: 50,
waitForInteraction: false,
}}
showsVerticalScrollIndicator={false}
data={allItems}
extraData={visibilityVersion}
/>
<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)
}
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 Width = 216
const previewUrl = selectedItem?.webpHighPreviewUrl || selectedItem?.url || ''
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: Width, width: Width, borderRadius: Width }} width={256} url={previewUrl} />
</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
isVisible: boolean
}
const GridItem = memo<GridItemProps>(function GridItem({ item, isSelected, itemWidth, onSelect, isVisible }) {
// console.log('item-------------', item);
const previewUrl = item?.webpPreviewUrl || item?.url || ''
// console.log('previewUrl-------------', isVisible)
// 使用压缩好了webp预览不可见情况下不渲染图片
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,
}}
>
{isVisible && (
<Img
src={previewUrl}
width={128}
isCompression={false}
style={{ height: itemWidth, width: itemWidth }}
isWebP={false}
/>
)}
{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>
)
})
export default Index