Compare commits

...

2 Commits

15 changed files with 111 additions and 41 deletions

2
.gitignore vendored
View File

@ -11,7 +11,7 @@ expo-env.d.ts
tmpclaude-* tmpclaude-*
*empclaude* *empclaude*
tmpclaude-* *tmpclaude-*
# Native # Native
.kotlin/ .kotlin/

View File

@ -28,7 +28,13 @@ jest.mock('@/components/icon', () => ({
// Mock components // Mock components
jest.mock('@/components/ErrorState', () => 'ErrorState') jest.mock('@/components/ErrorState', () => 'ErrorState')
jest.mock('@/components/LoadingState', () => 'LoadingState') jest.mock('@/components/LoadingState', () => 'LoadingState')
jest.mock('@/components/RefreshControl', () => 'RefreshControl')
// Mock react-native RefreshControl (directly from react-native)
jest.mock('react-native', () =>
Object.assign({}, jest.requireActual('react-native'), {
RefreshControl: 'RefreshControl',
})
)
// Mock dependencies // Mock dependencies
jest.mock('expo-router', () => ({ jest.mock('expo-router', () => ({

View File

@ -7,6 +7,10 @@ import {
Dimensions, Dimensions,
Pressable, Pressable,
StatusBar as RNStatusBar, StatusBar as RNStatusBar,
RefreshControl,
ActivityIndicator,
NativeScrollEvent,
NativeSyntheticEvent,
} from 'react-native' } from 'react-native'
import { StatusBar } from 'expo-status-bar' import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context' import { SafeAreaView } from 'react-native-safe-area-context'
@ -17,10 +21,10 @@ import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon'
import EditProfileDrawer from '@/components/drawer/EditProfileDrawer' import EditProfileDrawer from '@/components/drawer/EditProfileDrawer'
import Dropdown from '@/components/ui/dropdown' import Dropdown from '@/components/ui/dropdown'
import Toast from '@/components/ui/Toast' import Toast from '@/components/ui/Toast'
import { signOut } from '@/lib/auth' import { signOut , useSession } from '@/lib/auth'
import { useTemplateGenerations, type TemplateGeneration } from '@/hooks' import { useTemplateGenerations, type TemplateGeneration } from '@/hooks'
import { MySkeleton } from '@/components/skeleton/MySkeleton' import { MySkeleton } from '@/components/skeleton/MySkeleton'
import { useSession } from '@/lib/auth'
import { useUserBalance } from '@/hooks/use-user-balance' import { useUserBalance } from '@/hooks/use-user-balance'
const { width: screenWidth } = Dimensions.get('window') const { width: screenWidth } = Dimensions.get('window')
@ -30,9 +34,9 @@ const GALLERY_ITEM_SIZE = Math.floor(
(screenWidth - GALLERY_HORIZONTAL_PADDING * 2 - GALLERY_GAP * 2) / 3 (screenWidth - GALLERY_HORIZONTAL_PADDING * 2 - GALLERY_GAP * 2) / 3
) )
// 获取作品封面图 // 获取作品封面图 - Webp优先
const getCoverUrl = (item: TemplateGeneration) => const getCoverUrl = (item: TemplateGeneration) =>
item.resultUrl?.[0] || item.template?.coverImageUrl item.webpPreviewUrl || item.resultUrl?.[0] || item.template?.coverImageUrl
export default function My() { export default function My() {
const router = useRouter() const router = useRouter()
@ -58,16 +62,41 @@ export default function My() {
const { const {
generations, generations,
loading, loading,
error, loadingMore,
execute: loadGenerations,
refetch, refetch,
loadMore,
hasMore,
} = useTemplateGenerations() } = useTemplateGenerations()
// 初始化加载作品列表 // 初始化加载作品列表
useEffect(() => { useEffect(() => {
loadGenerations({ page: 1, limit: 50 }) refetch({ page: 1, limit: 50 })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// 下拉刷新状态
const [refreshing, setRefreshing] = useState(false)
// 下拉刷新处理
const onRefresh = useCallback(async () => {
setRefreshing(true)
await refetch({ page: 1, limit: 50 })
setRefreshing(false)
}, [refetch])
// 加载更多处理
const handleEndReached = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
const paddingToBottom = 100 // 距离底部100px时触发加载更多
if (
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom &&
!loadingMore &&
hasMore
) {
loadMore()
}
}, [loadingMore, hasMore, loadMore])
// 处理设置菜单选择 // 处理设置菜单选择
const handleSettingsSelect = async (value: string) => { const handleSettingsSelect = async (value: string) => {
if (value === 'changePassword') { if (value === 'changePassword') {
@ -193,6 +222,18 @@ export default function My() {
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={handleEndReached}
scrollEventThrottle={400}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#9966FF"
colors={['#9966FF', '#FF6699', '#FF9966']}
progressBackgroundColor="#1C1E22"
progressViewOffset={10}
/>
}
> >
<View style={styles.galleryGrid}> <View style={styles.galleryGrid}>
{generations.map((item, index) => ( {generations.map((item, index) => (
@ -247,6 +288,13 @@ export default function My() {
</Pressable> </Pressable>
))} ))}
{/* 加载更多指示器 */}
{loadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#9966FF" />
</View>
)}
{/* 空状态提示 */} {/* 空状态提示 */}
{!loading && generations.length === 0 && ( {!loading && generations.length === 0 && (
<View style={styles.emptyState}> <View style={styles.emptyState}>
@ -419,6 +467,12 @@ const styles = StyleSheet.create({
fontSize: 9, fontSize: 9,
fontWeight: '500', fontWeight: '500',
}, },
loadingMoreContainer: {
width: '100%',
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
emptyState: { emptyState: {
width: '100%', width: '100%',
alignItems: 'center', alignItems: 'center',

View File

@ -44,9 +44,15 @@ jest.mock('@/components/icon', () => ({
// Mock UI components // Mock UI components
jest.mock('@/components/LoadingState', () => 'LoadingState') jest.mock('@/components/LoadingState', () => 'LoadingState')
jest.mock('@/components/ErrorState', () => 'ErrorState') jest.mock('@/components/ErrorState', () => 'ErrorState')
jest.mock('@/components/RefreshControl', () => 'RefreshControl')
jest.mock('@/components/PaginationLoader', () => 'PaginationLoader') jest.mock('@/components/PaginationLoader', () => 'PaginationLoader')
// Mock react-native RefreshControl (directly from react-native)
jest.mock('react-native', () =>
Object.assign({}, jest.requireActual('react-native'), {
RefreshControl: 'RefreshControl',
})
)
// Mock hooks // Mock hooks
jest.mock('@/hooks', () => ({ jest.mock('@/hooks', () => ({
useTemplates: jest.fn(() => ({ useTemplates: jest.fn(() => ({

View File

@ -57,10 +57,12 @@ jest.mock('@/components/PaginationLoader', () => ({
default: ({ testID }: any) => <View testID={testID} />, default: ({ testID }: any) => <View testID={testID} />,
})) }))
jest.mock('@/components/RefreshControl', () => ({ // Mock react-native RefreshControl (directly from react-native)
__esModule: true, jest.mock('react-native', () =>
default: () => null, Object.assign({}, jest.requireActual('react-native'), {
})) RefreshControl: ({ children }: { children: React.ReactNode }) => <>{children}</>,
})
)
jest.mock('@/hooks', () => ({ jest.mock('@/hooks', () => ({
useTemplateGenerations: jest.fn(), useTemplateGenerations: jest.fn(),

View File

@ -56,7 +56,7 @@ export default function SearchResultsScreen() {
aspectRatio: template.aspectRatio ? parseFloat(template.aspectRatio as string) : undefined, aspectRatio: template.aspectRatio ? parseFloat(template.aspectRatio as string) : undefined,
})) }))
if (loading && !refreshing) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" /> <StatusBar style="light" />

View File

@ -80,7 +80,7 @@ describe('HeroSlider Component', () => {
describe('Callback Behavior', () => { describe('Callback Behavior', () => {
it('should call onActivityPress with link when activity is pressed', () => { it('should call onActivityPress with link when activity is pressed', () => {
const onActivityPress = jest.fn() const onActivityPress = jest.fn()
const props = { const props: { activities: typeof mockActivities; onActivityPress?: (link: string) => void } = {
activities: mockActivities, activities: mockActivities,
onActivityPress, onActivityPress,
} }
@ -90,7 +90,7 @@ describe('HeroSlider Component', () => {
}) })
it('should not throw when onActivityPress is not provided', () => { it('should not throw when onActivityPress is not provided', () => {
const props = { activities: mockActivities } const props: { activities: typeof mockActivities; onActivityPress?: (link: string) => void } = { activities: mockActivities }
expect(() => { expect(() => {
if (props.onActivityPress) { if (props.onActivityPress) {
props.onActivityPress(mockActivities[0].link) props.onActivityPress(mockActivities[0].link)

View File

@ -2,7 +2,7 @@ import React from 'react'
import { View, Text, Pressable, ScrollView, StyleSheet } from 'react-native' import { View, Text, Pressable, ScrollView, StyleSheet } from 'react-native'
import { Image } from 'expo-image' import { Image } from 'expo-image'
interface Activity { export interface Activity {
id: string id: string
title: string title: string
titleEn?: string titleEn?: string
@ -20,7 +20,7 @@ interface HeroSliderProps {
export function HeroSlider({ export function HeroSlider({
activities, activities,
onActivityPress, onActivityPress,
}: HeroSliderProps): JSX.Element | null { }: HeroSliderProps): React.ReactNode | null {
// 空数据时返回 null // 空数据时返回 null
if (!activities || activities.length === 0) { if (!activities || activities.length === 0) {
return null return null

View File

@ -35,7 +35,7 @@ export function TabNavigation({
isSticky = false, isSticky = false,
wrapperStyle, wrapperStyle,
onLayout, onLayout,
}: TabNavigationProps): JSX.Element { }: TabNavigationProps): React.ReactNode {
const scrollViewRef = useRef<ScrollView>(null) const scrollViewRef = useRef<ScrollView>(null)
const tabLayouts = useRef<{ x: number; width: number }[]>([]) const tabLayouts = useRef<{ x: number; width: number }[]>([])

View File

@ -1,5 +1,5 @@
import React, { memo, useCallback, useMemo } from 'react' import React, { memo, useCallback, useMemo } from 'react'
import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native' import { Pressable, StyleSheet, Text, View, ViewStyle, ImageStyle } from 'react-native'
import { Image } from 'expo-image' import { Image } from 'expo-image'
import { LinearGradient } from 'expo-linear-gradient' import { LinearGradient } from 'expo-linear-gradient'
@ -74,7 +74,7 @@ const TemplateCardComponent: React.FC<TemplateCardProps> = ({
[aspectRatio] [aspectRatio]
) )
const imageStyle = useMemo(() => const imageStyle = useMemo(() =>
[styles.cardImage, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ViewStyle[], [styles.cardImage, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ImageStyle[],
[aspectRatio] [aspectRatio]
) )

View File

@ -14,7 +14,7 @@ export function TitleBar({
onPointsPress, onPointsPress,
onSearchPress, onSearchPress,
onLayout, onLayout,
}: TitleBarProps): JSX.Element { }: TitleBarProps): React.ReactNode {
return ( return (
<View <View
testID="title-bar" testID="title-bar"

View File

@ -24,22 +24,22 @@ export const useChangePassword = (): ChangePasswordResult => {
// 客户端验证 // 客户端验证
if (!oldPassword) { if (!oldPassword) {
setError({ message: '旧密码不能为空' }) setError({ message: '旧密码不能为空', status: 400, statusText: 'Bad Request' })
return return
} }
if (newPassword.length < 6) { if (newPassword.length < 6) {
setError({ message: '新密码长度至少为6位' }) setError({ message: '新密码长度至少为6位', status: 400, statusText: 'Bad Request' })
return return
} }
if (oldPassword === newPassword) { if (oldPassword === newPassword) {
setError({ message: '新密码不能与当前密码相同' }) setError({ message: '新密码不能与当前密码相同', status: 400, statusText: 'Bad Request' })
return return
} }
if (confirmPassword !== undefined && newPassword !== confirmPassword) { if (confirmPassword !== undefined && newPassword !== confirmPassword) {
setError({ message: '新密码和确认密码不一致' }) setError({ message: '新密码和确认密码不一致', status: 400, statusText: 'Bad Request' })
return return
} }
@ -49,7 +49,7 @@ export const useChangePassword = (): ChangePasswordResult => {
const result = await handleError(async () => { const result = await handleError(async () => {
return await authClient.changePassword({ return await authClient.changePassword({
oldPassword, currentPassword: oldPassword,
newPassword, newPassword,
revokeOtherSessions: true, revokeOtherSessions: true,
}) })

View File

@ -8,7 +8,7 @@ import { OWNER_ID } from '@/lib/auth'
import { handleError } from './use-error' import { handleError } from './use-error'
type ListTemplatesParams = Omit<ListTemplatesInput, 'ownerId'> type ListTemplatesParams = Partial<Omit<ListTemplatesInput, 'ownerId'>>
const DEFAULT_PARAMS = { const DEFAULT_PARAMS = {
limit: 20, limit: 20,

View File

@ -17,9 +17,11 @@ import { type ApiError } from '@/lib/types'
import { handleError } from './use-error' import { handleError } from './use-error'
// Type definitions // Type definitions
export type WorksCategory = '全部' | '萌宠' | '写真' | '合拍'
export interface UseWorksSearchParams { export interface UseWorksSearchParams {
keyword: string keyword: string
category?: '全部' | '萌宠' | '写真' | '合拍' category?: WorksCategory
page?: number page?: number
limit?: number limit?: number
} }

View File

@ -8,7 +8,7 @@
*/ */
import { renderHook, waitFor, act } from '@testing-library/react-native' import { renderHook, waitFor, act } from '@testing-library/react-native'
import { useWorksSearch } from '@/hooks/use-works-search' import { useWorksSearch, type WorksCategory } from '@/hooks/use-works-search'
import { root } from '@repo/core' import { root } from '@repo/core'
import { TemplateGenerationController } from '@repo/sdk' import { TemplateGenerationController } from '@repo/sdk'
@ -21,15 +21,15 @@ jest.mock('@repo/core', () => ({
// Mock @tanstack/react-query before importing the hook // Mock @tanstack/react-query before importing the hook
const mockRefetch = jest.fn() const mockRefetch = jest.fn()
const mockUseQuery = jest.fn(() => ({ const mockUseQuery = jest.fn()
data: undefined,
isLoading: false, // eslint-disable-next-line @typescript-eslint/no-explicit-any
error: null, const mockUseQueryImpl = (...args: any[]) => {
refetch: mockRefetch, return mockUseQuery(args[0], args[1])
})) }
jest.mock('@tanstack/react-query', () => ({ jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn((args) => mockUseQuery(args)), useQuery: mockUseQueryImpl,
})) }))
describe('useWorksSearch', () => { describe('useWorksSearch', () => {
@ -267,16 +267,16 @@ describe('useWorksSearch', () => {
}) })
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ keyword, category }) => useWorksSearch({ keyword, category }), ({ keyword, category }: { keyword: string; category?: WorksCategory }) => useWorksSearch({ keyword, category }),
{ {
initialProps: { keyword: '测试', category: '萌宠' as const }, initialProps: { keyword: '测试', category: '萌宠' },
} }
) )
expect(result.current.works).toEqual(mockData1.data) expect(result.current.works).toEqual(mockData1.data)
// Switch category // Switch category
rerender({ keyword: '测试', category: '写真' as const }) rerender({ keyword: '测试', category: '写真' })
expect(result.current.works).toEqual(mockData2.data) expect(result.current.works).toEqual(mockData2.data)
}) })