From 1080fb131c114de1255fb903a455f841ca7d2464 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 28 Jan 2026 13:55:22 +0800 Subject: [PATCH] fix: bug --- app/(tabs)/__tests__/my.test.tsx | 152 +++++++ app/(tabs)/index.old.md | 657 ------------------------------- app/(tabs)/my.tsx | 4 +- 3 files changed, 154 insertions(+), 659 deletions(-) create mode 100644 app/(tabs)/__tests__/my.test.tsx delete mode 100644 app/(tabs)/index.old.md diff --git a/app/(tabs)/__tests__/my.test.tsx b/app/(tabs)/__tests__/my.test.tsx new file mode 100644 index 0000000..9bce14e --- /dev/null +++ b/app/(tabs)/__tests__/my.test.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react-native' +import My from '../my' + +jest.mock('expo-router', () => ({ + useRouter: () => ({ + push: jest.fn(), + replace: jest.fn(), + }), +})) + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'zh-CN', changeLanguage: jest.fn() }, + }), +})) + +jest.mock('@/lib/auth', () => ({ + signOut: jest.fn(), + useSession: () => ({ + data: { + user: { + id: 'test-user-id', + name: 'Test User', + username: 'testuser', + image: null, + }, + }, + }), +})) + +jest.mock('@/hooks/use-user-balance', () => ({ + useUserBalance: () => ({ + balance: 100, + }), +})) + +const mockRefetch = jest.fn() +const mockLoadMore = jest.fn() + +jest.mock('@/hooks', () => ({ + useTemplateGenerations: jest.fn(() => ({ + generations: [], + loading: false, + loadingMore: false, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + })), +})) + +describe('My Page - Pagination', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should load first page with limit 20 on mount', async () => { + render() + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + }) + + it('should refresh with limit 20 on pull to refresh', async () => { + const { useTemplateGenerations } = require('@/hooks') + const mockOnRefresh = jest.fn() + + useTemplateGenerations.mockReturnValue({ + generations: [{ id: '1', status: 'completed' }], + loading: false, + loadingMore: false, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + + render() + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + }) + + it('should call loadMore when scrolling near bottom', async () => { + const { useTemplateGenerations } = require('@/hooks') + + useTemplateGenerations.mockReturnValue({ + generations: Array(20).fill(null).map((_, i) => ({ + id: `${i}`, + status: 'completed', + resultUrl: ['url'], + })), + loading: false, + loadingMore: false, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + + render() + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + }) + + it('should not load more when loadingMore is true', async () => { + const { useTemplateGenerations } = require('@/hooks') + + useTemplateGenerations.mockReturnValue({ + generations: Array(20).fill(null).map((_, i) => ({ + id: `${i}`, + status: 'completed' + })), + loading: false, + loadingMore: true, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + + render() + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + }) + + it('should not load more when hasMore is false', async () => { + const { useTemplateGenerations } = require('@/hooks') + + useTemplateGenerations.mockReturnValue({ + generations: Array(10).fill(null).map((_, i) => ({ + id: `${i}`, + status: 'completed' + })), + loading: false, + loadingMore: false, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: false, + }) + + render() + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + }) +}) diff --git a/app/(tabs)/index.old.md b/app/(tabs)/index.old.md deleted file mode 100644 index 3bf7f08..0000000 --- a/app/(tabs)/index.old.md +++ /dev/null @@ -1,657 +0,0 @@ -import { FlatList } from 'react-native'; -import { Image } from 'expo-image'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; -import { memo as ReactMemo, useEffect, useRef, useState } from 'react'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Dimensions, - Platform, - Pressable, - StatusBar as RNStatusBar, - ScrollView, - StyleSheet, - Text, - View -} from 'react-native'; -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { DownArrowIcon, PointsIcon, SearchIcon } from '@/components/icon'; -import ErrorState from '@/components/ErrorState'; -import LoadingState from '@/components/LoadingState'; -import { useActivates } from '@/hooks/use-activates'; -import { useCategories } from '@/hooks/use-categories'; -import { CategoryTemplate } from '@repo/sdk'; - -const { width: screenWidth } = Dimensions.get('window') - -// 卡片组件 - 使用 useCallback 缓存以优化 FlashList 性能 -const Card = ReactMemo(({ card, cardWidth, t, onPress }: { - card: CategoryTemplate & { webpPreviewUrl?: string } - cardWidth: number - t: any - onPress: (id: string) => void -}) => { - // 解析 aspectRatio 字符串为数字(如 "128:128" -> 1) - const aspectRatio = card.aspectRatio?.includes(':') - ? (() => { - const [w, h] = card.aspectRatio.split(':').map(Number) - return w / h - })() - : card.aspectRatio - - return ( - onPress(card.id!)} - > - - - - - {card.title} - - - - ) -}) - - -export default function HomeScreen() { - const { t } = useTranslation() - const insets = useSafeAreaInsets() - const router = useRouter() - const params = useLocalSearchParams() - const [activeTab, setActiveTab] = useState(0) - const [selectedCategoryId, setSelectedCategoryId] = useState(null) - - const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories() - const { load, data: activatesData, error } = useActivates() - - useEffect(() => { - load() - loadCategories() - }, []) - - useEffect(() => { - // 当分类数据加载完成后,默认选中第一个分类 - if (categoriesData?.categories && categoriesData.categories.length > 0 && !selectedCategoryId) { - setSelectedCategoryId(categoriesData.categories[0].id) - } - }, [categoriesData, categoriesError]) - - // 监听从 channels 页面传递过来的 categoryId 参数 - useEffect(() => { - const categoryIdFromParams = params.categoryId as string | undefined - if (categoryIdFromParams && categoriesData?.categories) { - setSelectedCategoryId(categoryIdFromParams) - // 同时更新 activeTab 索引 - const categoryIndex = categoriesData.categories.findIndex(cat => cat.id === categoryIdFromParams) - if (categoryIndex !== -1) { - setActiveTab(categoryIndex) - } - } - }, [params.categoryId, categoriesData]) - - // 使用接口返回的分类数据,如果没有则使用默认翻译 - const categories = categoriesData?.categories || [] - const tabs = categories.length > 0 - ? categories.map(cat => cat.name) - : [ - t('home.tabs.featured'), - t('home.tabs.christmas'), - t('home.tabs.pets'), - t('home.tabs.avatar'), - t('home.tabs.theater1'), - t('home.tabs.theater2'), - ] - - // 获取当前选中分类的模板数据 - const currentCategory = categories.find(cat => cat.id === selectedCategoryId) - const categoryTemplates = currentCategory?.templates || [] - - // 将 CategoryTemplate 数据转换为卡片数据格式 - const displayCardData = categoryTemplates.length > 0 - ? categoryTemplates - .filter((template) => { - // 过滤掉视频类型的模板,只显示图片 - const previewUrl = (template as any).webpPreviewUrl || template.previewUrl || template.coverImageUrl || `` - const isVideo = previewUrl.includes('.mp4') || previewUrl.includes('.mov') || previewUrl.includes('.webm') - return !isVideo - }) - : [] - const [gridWidth, setGridWidth] = useState(screenWidth) - const [showTabArrow, setShowTabArrow] = useState(false) - const [tabsSticky, setTabsSticky] = useState(false) - const [tabsHeight, setTabsHeight] = useState(0) - const tabsPositionRef = useRef(0) - const titleBarHeightRef = useRef(0) - - const horizontalPadding = 8 * 2 // gridContainer 的左右 padding - const cardGap = 5 // 两个卡片之间的间距 - const numColumns = 3 // 每行显示3个卡片 - const cardWidth = (gridWidth - horizontalPadding - cardGap * (numColumns - 1)) / numColumns - - // 判断是否显示加载状态 - const showLoading = categoriesLoading - - // 判断是否显示分类空状态 - const showEmptyState = !categoriesLoading && categoriesData?.categories && categoriesData.categories.length === 0 - - // 判断是否显示模板空状态(当前分类下没有模板) - const showEmptyTemplates = !categoriesLoading && !showEmptyState && displayCardData.length === 0 - - // 渲染标签导航的函数 - const renderTabs = (wrapperStyle?: any) => ( - - { - const tabsContainerPadding = 16 * 2 // tabsContent 的左右 padding - const availableWidth = screenWidth - tabsContainerPadding - setShowTabArrow(contentWidth > availableWidth) - }} - > - {tabs.map((tab, index) => ( - { - setActiveTab(index) - // 设置选中的分类ID - if (categories.length > 0 && categories[index]) { - setSelectedCategoryId(categories[index].id) - } else { - setSelectedCategoryId(null) - } - }} - style={styles.tab} - > - - {activeTab === index && ( - - )} - - {tab} - - - - ))} - - {showTabArrow && ( - - - router.push({ - pathname: '/channels' as any, - params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined, - })} - > - - - - - )} - - ) - - return ( - - - - {/* 标题栏 */} - { - const { height } = event.nativeEvent.layout - titleBarHeightRef.current = height - }} - > - Popcore - - router.push('/membership' as any)} - > - - 60 - - router.push('/searchTemplate')} - > - - - - - {/* 吸顶的标签导航 - 适配 iOS 和 Android 的安全区域 */} - {tabsSticky && !showLoading && !showEmptyState && ( - - {renderTabs(styles.stickyTabs)} - - )} - { - const scrollY = event.nativeEvent.contentOffset.y - if (scrollY >= tabsPositionRef.current) { - setTabsSticky(true) - } else { - setTabsSticky(false) - } - }} - // iOS 使用 16ms (60fps),Android 使用 50ms 以获得更好的性能 - scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50} - > - {/* 图片区域 */} - - - {activatesData?.activities.map((activity) => ( - router.push(activity.link as any)}> - - - {activity.title} - {activity.desc} - - - ))} - - - - {/* 标签导航 - 只要有分类数据就显示标签 */} - {!showLoading && !showEmptyState && ( - { - const { y, height } = event.nativeEvent.layout - tabsPositionRef.current = y - setTabsHeight(height) - }} - style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined} - > - {renderTabs()} - - )} - - {/* 加载状态 */} - {showLoading && } - - {/* 错误状态 - 分类加载失败 */} - {categoriesError && ( - loadCategories()} /> - )} - - {/* 空状态 - 分类数据为空 */} - {showEmptyState && !categoriesError && ( - loadCategories()} /> - )} - - {/* 空状态 - 当前分类下没有模板 */} - {showEmptyTemplates && ( - loadCategories()} /> - )} - - {/* 内容网格 - 使用 FlashList 优化性能 */} - {!showLoading && !showEmptyState && !showEmptyTemplates && ( - { - const { width } = event.nativeEvent.layout - setGridWidth(width) - }} - > - ( - { - router.push({ - pathname: '/templateDetail' as any, - params: { id: id.toString() }, - }) - }} - /> - )} - keyExtractor={(item) => item.id!} - numColumns={numColumns} - showsVerticalScrollIndicator={false} - scrollEnabled={false} - /> - - )} - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#090A0B', - }, - header: { - backgroundColor: '#090A0B', - paddingTop: 8, - paddingBottom: 12, - }, - statusBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingBottom: 8, - }, - time: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '600', - }, - statusIcons: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - signalBars: { - width: 18, - height: 12, - backgroundColor: '#FFFFFF', - borderRadius: 2, - }, - wifiIcon: { - width: 16, - height: 12, - backgroundColor: '#FFFFFF', - borderRadius: 2, - }, - batteryIcon: { - width: 24, - height: 12, - backgroundColor: '#FFFFFF', - borderRadius: 2, - }, - titleBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingBottom: 7, - paddingTop: 19, - }, - appTitle: { - color: '#F5F5F5', - fontSize: 18, - fontWeight: '500', - }, - headerRight: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - pointsContainer: { - backgroundColor: '#1C1E22', - borderRadius: 12, - paddingLeft: 8, - paddingRight: 10, - paddingVertical: 4, - flexDirection: 'row', - alignItems: 'center', - }, - pointsText: { - color: '#FFCF00', - fontSize: 12, - fontWeight: '600', - }, - searchButton: { - padding: 4, - }, - scrollView: { - flex: 1, - backgroundColor: '#090A0B', - }, - scrollContent: { - flexGrow: 1, - backgroundColor: '#090A0B', - }, - heroSection: { - flexDirection: 'row', - paddingLeft: 12, - paddingTop: 12, - marginBottom: 40, - overflow: 'hidden', - }, - heroSliderContent: { - gap: 12, - }, - heroMainSlide: { - width: '100%', - }, - heroMainImage: { - width: 265, - height: 150, - borderRadius: 12, - }, - heroTextContainer: { - paddingTop: 12, - paddingHorizontal: 8, - }, - heroText: { - color: '#ABABAB', - fontSize: 12, - marginBottom: 4, - }, - heroSubtext: { - color: '#F5F5F5', - fontSize: 16, - fontWeight: '500', - }, - heroSide: { - width: 120, - borderRadius: 12, - overflow: 'hidden', - }, - heroSideImage: { - width: '100%', - height: 140, - }, - heroSideTextContainer: { - padding: 8, - backgroundColor: 'rgba(0, 0, 0, 0.6)', - }, - heroSideText: { - color: '#FFFFFF', - fontSize: 12, - fontWeight: '500', - marginBottom: 2, - }, - heroSideSubtext: { - color: '#FFFFFF', - fontSize: 11, - opacity: 0.9, - }, - tabsWrapper: { - position: 'relative', - marginBottom: 18, - }, - stickyTabsWrapper: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 100, - backgroundColor: '#090A0B', - }, - stickyTabs: { - marginBottom: 0, - }, - tabsContainer: { - marginBottom: 0, - }, - tabsContent: { - paddingHorizontal: 16, - gap: 20, - alignItems: 'center', - }, - tabsContentWithArrow: { - paddingRight: 60, - }, - tab: { - paddingBottom: 4, - position: 'relative', - }, - tabLabelWrapper: { - position: 'relative', - paddingBottom: 2, - alignSelf: 'flex-start', - justifyContent: 'flex-end', - }, - tabText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '600', - }, - tabTextActive: { - opacity: 1, - fontWeight: '600', - }, - tabUnderline: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: 12, - }, - tabArrowContainer: { - position: 'absolute', - right: 0, - top: 0, - zIndex: 10, - }, - tabArrowGradient: { - paddingLeft: 30, - paddingRight: 16, - paddingVertical: 4, - }, - tabArrow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - gridContainer: { - flex: 1, - paddingHorizontal: 8, - }, - card: { - marginBottom: 12, - paddingHorizontal: 5, - }, - flashListContent: { - gap: 10, - }, - cardImageContainer: { - width: '100%', - borderRadius: 16, - overflow: 'hidden', - position: 'relative', - }, - cardImage: { - width: '100%', - height: '100%', - }, - cardImageGradient: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: '33.33%', - }, - hotBadge: { - position: 'absolute', - top: 8, - left: 8, - backgroundColor: '#191A1F80', - paddingHorizontal: 7, - paddingVertical: 4, - borderRadius: 100, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 1, - }, - hotEmoji: { - fontSize: 10, - }, - hotText: { - color: '#F5F5F5', - fontSize: 11, - fontWeight: '500', - }, - cardTitle: { - position: 'absolute', - bottom: 12, - left: 12, - fontSize: 14, - fontWeight: '500', - color: '#F5F5F5', - lineHeight: 20, - }, -}) diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index 31c7eee..d05efb5 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -70,7 +70,7 @@ export default function My() { // 初始化加载作品列表 useEffect(() => { - refetch({ page: 1, limit: 50 }) + refetch({ page: 1, limit: 20 }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -80,7 +80,7 @@ export default function My() { // 下拉刷新处理 const onRefresh = useCallback(async () => { setRefreshing(true) - await refetch({ page: 1, limit: 50 }) + await refetch({ page: 1, limit: 20 }) setRefreshing(false) }, [refetch])