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])