diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index bc03e63..8c18a72 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,6 +1,5 @@ import { Ionicons } from '@expo/vector-icons' import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components' -import Img from '@share/components/Img' import { FlashList } from '@shopify/flash-list' import { LinearGradient } from 'expo-linear-gradient' import { router, useFocusEffect } from 'expo-router' @@ -79,7 +78,7 @@ const Index = observer(function Index() { isDeleted?: boolean } - const transformTemplateToMediaItem = (template: TemplateData): MediaItem => { + const transformTemplateToMediaItem = useCallback((template: TemplateData): MediaItem => { const isVideo = template.previewUrl?.includes('.mp4') return { id: template.id, @@ -89,7 +88,7 @@ const Index = observer(function Index() { likeCount: template.likeCount || 0, price: template.price || 2, } - } + }, []) /** ================= 统一请求函数 ================= */ @@ -136,7 +135,54 @@ const Index = observer(function Index() { setHasMore(false) } - setAllItems((prev) => (mode === 'loadMore' ? [...prev, ...newItems] : newItems)) + const updatedItems = mode === 'loadMore' ? [...allItems, ...newItems] : newItems + + // 检测重复的 id 并打印 + const checkDuplicateItems = (items: MediaItem[]) => { + const idMap = new Map() + + // 将items按id分组 + items.forEach((item) => { + if (!idMap.has(item.id)) { + idMap.set(item.id, []) + } + idMap.get(item.id)!.push(item) + }) + + // 找出有重复id的项目 + const duplicates: { id: string; items: MediaItem[] }[] = [] + idMap.forEach((itemsWithSameId, id) => { + if (itemsWithSameId.length > 1) { + duplicates.push({ id, items: itemsWithSameId }) + } + }) + + if (duplicates.length > 0) { + console.log('🚨 发现重复的items:') + duplicates.forEach(({ id, items }) => { + console.log(`ID: ${id} 重复了 ${items.length} 次:`) + items.forEach((item, index) => { + console.log(` [${index + 1}]`, { + id: item.id, + type: item.type, + url: item.url, + likeCount: item.likeCount, + price: item.price, + }) + }) + console.log('---') + }) + } else { + console.log('✅ 没有发现重复的items') + } + + return duplicates + } + + // 检测并打印重复项 + checkDuplicateItems(updatedItems) + + setAllItems(updatedItems) } catch (e) { console.error('fetchList error:', e) setHasMore(false) @@ -154,7 +200,7 @@ const Index = observer(function Index() { /** ================= tab 切换(抽象后的重点) ================= */ - const changeTab = (tab: ActiveTab) => { + const changeTab = useCallback((tab: ActiveTab) => { queryRef.current = { ...queryRef.current, tab, @@ -163,11 +209,11 @@ const Index = observer(function Index() { setHasMore(true) firstLoadDoneRef.current = false fetchList('init') - } + }, []) /** ================= 搜索 ================= */ - const handleSearch = (text: string) => { + const handleSearch = useCallback((text: string) => { queryRef.current = { ...queryRef.current, search: text, @@ -176,18 +222,18 @@ const Index = observer(function Index() { setHasMore(true) firstLoadDoneRef.current = false fetchList('refresh') - } + }, []) /** ================= 刷新 / 加载更多 ================= */ - const handleRefresh = () => { + const handleRefresh = useCallback(() => { queryRef.current.page = 1 setHasMore(true) firstLoadDoneRef.current = false fetchList('refresh') - } + }, []) - const handleLoadMore = () => { + const handleLoadMore = useCallback(() => { // 防止重复请求或首次渲染时误触发 if (loadingRef.current || !hasMore || refreshing || loadingMore) return if (!firstLoadDoneRef.current) return @@ -196,7 +242,7 @@ const Index = observer(function Index() { queryRef.current.page += 1 fetchList('loadMore') console.log('handleLoadMore-------------') - } + }, [hasMore, refreshing, loadingMore]) /** ================= selectedItem 修正 ================= */ @@ -262,17 +308,25 @@ const Index = observer(function Index() { Toast.show({ title: '请先选择一个模板' }) return } - if (balance < selectedItem.price) { - Toast.show({ title: '余额不足,请充值' }) + + // 显示加载状态并刷新余额 + 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() } - await userBalanceStore.load(true) // 生成前刷新余额 - // 如果余额仍不足,提示充值 - if (balance < selectedItem.price) { - Toast.show({ title: '余额不足,请充值' }) - return - } Toast.showModal( { Toast.hideModal() - // 先进行乐观更新,扣减余额 - userBalanceStore.deductBalance(selectedItem.price) + Toast.showLoading() - const { generationId, error } = await runTemplate({ - templateId: selectedItem.id, - data: {}, - originalUrl: selectedItem.url, - }) - if (generationId && user?.id) { - // 生成成功后强制刷新余额以获取准确数据 - userBalanceStore.load(true) - } else { - // 生成失败,恢复余额 + try { + // 先进行乐观更新,扣减余额 + userBalanceStore.deductBalance(selectedItem.price) + + const { generationId, error } = await runTemplate({ + templateId: selectedItem.id, + data: {}, + originalUrl: selectedItem.url, + }) + + 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: error?.message || '生成失败' }) + Toast.show({ title: '网络异常,请重试' }) + } finally { + Toast.hideLoading() } }} />, @@ -329,6 +395,21 @@ const Index = observer(function Index() { } } + // 缓存渲染函数以提升性能 + const renderItem = useCallback( + ({ item }: { item: MediaItem }) => ( + setSelectedItem(item)} + /> + ), + [selectedItem?.id], + ) + + const keyExtractor = useCallback((item: MediaItem) => item.id, []) + /** ================= UI ================= */ return ( @@ -368,14 +449,8 @@ const Index = observer(function Index() { drawDistance={1200} onEndReachedThreshold={0.3} refreshControl={} - renderItem={({ item }) => ( - setSelectedItem(item)} - /> - )} + renderItem={renderItem} + keyExtractor={keyExtractor} ListEmptyComponent={renderListEmpty} showsVerticalScrollIndicator={false} data={allItems} @@ -398,33 +473,29 @@ type SearchOverlayProps = { const SearchOverlay = memo(function SearchOverlay({ isOpen, onChange, onClose, onSearch }) { const timerRef = useRef | null>(null) const [searchText, setSearchText] = useState('') - const handleTextChange = (text: string) => { - setSearchText(text) - onChange && onChange(text) - if (timerRef.current) { - clearTimeout(timerRef.current) - } + const handleTextChange = useCallback( + (text: string) => { + setSearchText(text) + onChange?.(text) - timerRef.current = setTimeout(() => { - onSearch(text) - }, 2000) - } - - useEffect(() => { - // 删除定时器 - return () => { if (timerRef.current) { clearTimeout(timerRef.current) } - } - }, []) - const handleClose = () => { + // 增加防抖时间到500ms,减少频繁搜索 + timerRef.current = setTimeout(() => { + onSearch(text) + }, 500) + }, + [onChange, onSearch], + ) + + const handleClose = useCallback(() => { setSearchText('') onSearch('') - onClose && onClose() - } + onClose?.() + }, [onClose, onSearch]) if (!isOpen) return null return ( @@ -516,11 +587,7 @@ const HeroCircle = memo(function HeroCircle({ selectedItem, onQ - {selectedItem.type === 'video' ? ( - - ) : ( - - )} + void } -const GridItem = memo(function GridItem({ item, isSelected, itemWidth, onSelect }) { - // console.log('item-------------', item); - return ( - - - - - {isSelected && } +// 优化GridItem组件,减少不必要的重渲染 +const GridItem = memo( + function GridItem({ item, isSelected, itemWidth, onSelect }) { + // console.log('item-------------', item); + return ( + - + + {isSelected && } + + - {item.id} - - + + {item.id} + + - - + + - - - - {item.id} - - - - - {item.likeCount} + + + + {item.id} + + + + + {item.likeCount} + - - ) -}) + ) + }, + (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 diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx deleted file mode 100644 index 958ca57..0000000 --- a/app/(tabs)/my.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import { Ionicons } from '@expo/vector-icons' -import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components' -import Img from '@share/components/Img' -import { LinearGradient } from 'expo-linear-gradient' -import { router } from 'expo-router' -import { observer } from 'mobx-react-lite' -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { Dimensions, ScrollView } from 'react-native' - -import { useTemplateActions } from '@/hooks/actions/use-template-actions' -import { type TemplateGeneration, useTemplateGenerations } from '@/hooks/data/use-template-generations' -import { userBalanceStore, userStore } from '@/stores' - -const BACKGROUND_VIDEOS = [ - 'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4', - 'https://cdn.roasmax.cn/material/992e6c5d940c42feb71c27e556b754c0.mp4', - 'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4', -] - -function isVideoUrl(url: string) { - return url.endsWith('.mp4') -} - -const My = observer(function My() { - // 从MobX Store获取用户信息 - const { user, isLoading: authLoading, signOut } = userStore - const { data: generationsData, loading: generationsLoading, load: loadGenerations } = useTemplateGenerations() - const { batchDeleteGenerations, loading: deleteLoading } = useTemplateActions() - - // 使用MobX Store中的余额信息 - const { balance } = userBalanceStore - - const [activeFilter, setActiveFilter] = useState<'my_gen' | 'my_album'>('my_gen') - const [bgVideo] = useState(() => BACKGROUND_VIDEOS[Math.floor(Math.random() * BACKGROUND_VIDEOS.length)]) - const [isSelectionMode, setIsSelectionMode] = useState(false) - const [selectedIds, setSelectedIds] = useState>(new Set()) - - useEffect(() => { - if (user?.id) { - loadGenerations({ userId: user.id }) - userBalanceStore.load(true) // 加载用户余额 - } - }, [user?.id]) - - const generations = useMemo(() => generationsData?.data || [], [generationsData]) - const filteredPosts = - activeFilter === 'my_gen' ? generations : generations.filter((g: TemplateGeneration) => g.status === 'completed') - - const toggleSelectionMode = useCallback(() => { - setIsSelectionMode((v) => !v) - setSelectedIds(new Set()) - }, []) - - const handleItemClick = useCallback( - (generation: any) => { - if (isSelectionMode) { - setSelectedIds((prev) => { - const next = new Set(prev) - if (next.has(generation.id)) next.delete(generation.id) - else next.add(generation.id) - return next - }) - } - }, - [isSelectionMode], - ) - - const handleDelete = useCallback(async () => { - if (selectedIds.size === 0) return - - const { success } = await batchDeleteGenerations(Array.from(selectedIds)) - - if (success && user?.id) { - await loadGenerations({ userId: user.id }) - setSelectedIds(new Set()) - setIsSelectionMode(false) - } - }, [selectedIds, batchDeleteGenerations, user?.id, loadGenerations]) - - const selectAll = useCallback(() => { - if (selectedIds.size === filteredPosts.length) setSelectedIds(new Set()) - else setSelectedIds(new Set(filteredPosts.map((p: TemplateGeneration) => p.id))) - }, [selectedIds.size, filteredPosts]) - - const handleLogout = useCallback(() => { - Toast.showModal( - { - Toast.hideModal() - await signOut() - router.replace('/auth') - }} - onCancel={() => Toast.hideModal()} - />, - {}, - ) - }, [signOut]) - - const { width: screenWidth } = Dimensions.get('window') - const itemWidth = Math.floor((screenWidth - 24 - 12 * 2) / 3) - - const renderBanner = useCallback( - () => ( - - - - - ), - [bgVideo], - ) - - const renderHeaderCard = useCallback(() => { - const username = user?.name || user?.email || 'Guest' - const avatarUrl = - user?.image || 'https://image.pollinations.ai/prompt/cool%20anime%20boy%20avatar%20hoodie?seed=123&nologo=true' - const uid = user?.id?.slice(-6) || '000000' - const generationCount = generations.length - const completedCount = generations.filter((g: TemplateGeneration) => g.status === 'completed').length - - return ( - - - - - - - - - - - {username} - - - - - - - - UID: {uid} - - - CREDITS: {balance} - - - - - TOTAL - {generationCount} - - - COMPLETED - {completedCount} - - - CREDITS - {balance} - - - - - {balance > 100 && ( - - PRO - - )} - - - ) - }, [user, balance, generations]) - - const renderActions = useCallback( - () => ( - - {[ - { label: 'SHOP', color: '#4ADE80', icon: 'bag-outline' as const }, - { label: 'SYNC', color: '#FFE500', icon: 'watch-outline' as const }, - { label: 'PAY', color: '#e61e25', icon: 'card-outline' as const }, - ].map(({ label, color, icon }) => ( - - - {label} - - ))} - - ), - [], - ) - - const renderFilters = useCallback( - () => ( - - - {['我的生成', '我的专辑'].map((label) => { - const target = label === '我的生成' ? 'my_gen' : 'my_album' - const isActive = activeFilter === (target as 'my_gen' | 'my_album') - return ( - setActiveFilter(target as 'my_gen' | 'my_album')} - className={`border-2 border-black px-[16px] py-[4px] ${isActive ? 'bg-black' : 'bg-white'} -skew-x-12`} - > - - {label} - - - ) - })} - - - {isSelectionMode ? '取消' : '管理'} - - - ), - [activeFilter, isSelectionMode, toggleSelectionMode], - ) - - const renderGrid = useCallback(() => { - if (filteredPosts.length === 0) { - return ( - - - - - {activeFilter === 'my_gen' ? '暂无生成作品' : '暂无收藏'} - - - - ) - } - - return ( - - - {filteredPosts.map((generation: TemplateGeneration) => { - const isSelected = selectedIds.has(generation.id) - const imageUrl = generation.resultUrl?.[0] || generation.originalUrl || '' - const statusRank = generation.status === 'completed' ? 'S' : generation.status === 'processing' ? 'A' : 'B' - - return ( - handleItemClick(generation)} className="relative"> - - - {imageUrl ? ( - isVideoUrl(imageUrl) ? ( - - ) : ( - - ) - ) : ( - - - - )} - - {isSelected && } - - {isSelectionMode && ( - - - - - - )} - {!isSelectionMode && ( - - {statusRank} - - )} - {!isSelectionMode && ( - - - - {generation.type} - - - - - {new Date(generation.createdAt).toLocaleDateString()} - - - - )} - - - ) - })} - - - - ) - }, [filteredPosts, selectedIds, isSelectionMode, handleItemClick, activeFilter, itemWidth]) - - const renderSelection = useCallback(() => { - if (!isSelectionMode) return null - return ( - - - - 已选: {selectedIds.size} - - 全选 - - - 0 && !deleteLoading ? 'bg-[#e61e25]' : 'bg-gray-200'}`} - > - {deleteLoading ? ( - - ) : ( - - )} - {deleteLoading ? '删除中...' : '删除'} - - - - ) - }, [isSelectionMode, selectedIds.size, selectAll, handleDelete, deleteLoading]) - if (authLoading || generationsLoading) { - return ( - - {renderBanner()} - - - 加载中... - - - ) - } - - return ( - - {renderBanner()} - - {renderHeaderCard()} - {renderActions()} - {renderFilters()} - {renderGrid()} - - {renderSelection()} - - ) -}) - -export default My diff --git a/app/(tabs)/sync.tsx b/app/(tabs)/sync.tsx index 5395c65..baabf04 100644 --- a/app/(tabs)/sync.tsx +++ b/app/(tabs)/sync.tsx @@ -3,7 +3,7 @@ import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components' import Img from '@share/components/Img' import { FlashList } from '@shopify/flash-list' import * as ImagePicker from 'expo-image-picker' -import { router } from 'expo-router' +import { router, useFocusEffect } from 'expo-router' import { observer } from 'mobx-react-lite' import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' import { ActivityIndicator, RefreshControl, ScrollView, View } from 'react-native' @@ -68,6 +68,16 @@ const Sync = observer(() => { } }, [user?.id, loadGenerations]) + // 页面聚焦时重新请求数据 + useFocusEffect( + useCallback(() => { + if (user?.id) { + console.log('页面聚焦,重新请求数据') + loadGenerations() + } + }, [user?.id, loadGenerations]), + ) + // 将生成记录转换为 posts 格式 const posts = useMemo(() => { const generations = generationsData?.data || []