Compare commits
2 Commits
c78ad352ba
...
cd1a4f6841
| Author | SHA1 | Date |
|---|---|---|
|
|
cd1a4f6841 | |
|
|
8f00d4644a |
|
|
@ -11,7 +11,7 @@ expo-env.d.ts
|
||||||
tmpclaude-*
|
tmpclaude-*
|
||||||
|
|
||||||
*empclaude*
|
*empclaude*
|
||||||
tmpclaude-*
|
*tmpclaude-*
|
||||||
|
|
||||||
# Native
|
# Native
|
||||||
.kotlin/
|
.kotlin/
|
||||||
|
|
|
||||||
|
|
@ -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', () => ({
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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(() => ({
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }[]>([])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue