expo-popcore-app/app/(tabs)/my.tsx

761 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
StatusBar as RNStatusBar,
RefreshControl,
ActivityIndicator,
NativeScrollEvent,
NativeSyntheticEvent,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { PanGestureHandler } from 'react-native-gesture-handler'
import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon'
import EditProfileDrawer from '@/components/drawer/EditProfileDrawer'
import Dropdown from '@/components/ui/dropdown'
import Toast from '@/components/ui/Toast'
import { signOut, useSession } from '@/lib/auth'
import {
useTemplateGenerations,
useUserFavorites,
useUserLikes,
type TemplateGeneration
} from '@/hooks'
import { MySkeleton } from '@/components/skeleton/MySkeleton'
import { TabNavigation } from '@/components/blocks/home/TabNavigation'
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
import { useUserBalance } from '@/hooks/use-user-balance'
const { width: screenWidth } = Dimensions.get('window')
const GALLERY_GAP = 2
const GALLERY_HORIZONTAL_PADDING = 0
const GALLERY_ITEM_SIZE = Math.floor(
(screenWidth - GALLERY_HORIZONTAL_PADDING * 2 - GALLERY_GAP * 2) / 3
)
type TabType = 'works' | 'favorites' | 'likes'
// 获取作品封面图 - Webp优先
const getCoverUrl = (item: TemplateGeneration) =>
item.webpPreviewUrl || item.resultUrl?.[0] || item.template?.coverImageUrl
export default function My() {
const router = useRouter()
const { t, i18n } = useTranslation()
const [editDrawerVisible, setEditDrawerVisible] = useState(false)
// Tab 状态
const [activeTab, setActiveTab] = useState<TabType>('works')
const [activeTabIndex, setActiveTabIndex] = useState(0)
// 获取积分余额
const { balance } = useUserBalance()
// 获取当前登录用户信息
const { data: session } = useSession()
const userName = session?.user?.name || session?.user?.username || '用户'
const [profileName, setProfileName] = useState(userName)
// 当 session 变化时更新用户名
useEffect(() => {
if (session?.user?.name || session?.user?.username) {
setProfileName(session?.user?.name || session?.user?.username || '用户')
}
}, [session])
// 使用 useTemplateGenerations hook 获取用户作品列表
const {
generations,
loading,
loadingMore,
refetch,
loadMore,
hasMore,
} = useTemplateGenerations()
// 获取收藏列表
const {
favorites,
loading: favoritesLoading,
loadingMore: favoritesLoadingMore,
refetch: favoritesRefetch,
loadMore: favoritesLoadMore,
hasMore: favoritesHasMore,
} = useUserFavorites()
// 获取点赞列表
const {
likes,
loading: likesLoading,
loadingMore: likesLoadingMore,
refetch: likesRefetch,
loadMore: likesLoadMore,
hasMore: likesHasMore,
} = useUserLikes()
// Tab 配置
const tabs = [
t('my.tabs.works'),
t('my.tabs.favorites'),
t('my.tabs.likes')
]
// 处理 Tab 切换
const handleTabPress = useCallback((index: number) => {
setActiveTabIndex(index)
if (index === 0) setActiveTab('works')
else if (index === 1) setActiveTab('favorites')
else if (index === 2) setActiveTab('likes')
}, [])
// 左右滑动切换 Tab
const handleSwipeLeft = useCallback(() => {
if (activeTabIndex < tabs.length - 1) {
handleTabPress(activeTabIndex + 1)
}
}, [activeTabIndex, tabs.length, handleTabPress])
const handleSwipeRight = useCallback(() => {
if (activeTabIndex > 0) {
handleTabPress(activeTabIndex - 1)
}
}, [activeTabIndex, handleTabPress])
const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({
onSwipeLeft: handleSwipeLeft,
onSwipeRight: handleSwipeRight,
canSwipeLeft: activeTabIndex < tabs.length - 1,
canSwipeRight: activeTabIndex > 0,
})
// 调试日志
useEffect(() => {
console.log('📊 作品列表状态:', {
总数: generations.length,
加载中: loading,
加载更多中: loadingMore,
还有更多: hasMore
})
}, [generations.length, loading, loadingMore, hasMore])
// 初始化加载数据
useEffect(() => {
refetch({ page: 1, limit: 20 })
favoritesRefetch()
likesRefetch()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 下拉刷新状态
const [refreshing, setRefreshing] = useState(false)
const [favoritesRefreshing, setFavoritesRefreshing] = useState(false)
const [likesRefreshing, setLikesRefreshing] = useState(false)
// 防止重复触发加载更多
const isLoadingMoreRef = useRef(false)
const isFavoritesLoadingMoreRef = useRef(false)
const isLikesLoadingMoreRef = useRef(false)
// 下拉刷新处理
const onRefresh = useCallback(async () => {
setRefreshing(true)
try {
await refetch({ page: 1, limit: 20 })
} finally {
setRefreshing(false)
}
}, [refetch])
// 收藏Tab下拉刷新处理
const onFavoritesRefresh = useCallback(async () => {
setFavoritesRefreshing(true)
try {
await favoritesRefetch()
} finally {
setFavoritesRefreshing(false)
}
}, [favoritesRefetch])
// 点赞Tab下拉刷新处理
const onLikesRefresh = useCallback(async () => {
setLikesRefreshing(true)
try {
await likesRefetch()
} finally {
setLikesRefreshing(false)
}
}, [likesRefetch])
// 加载更多处理
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
const paddingToBottom = 200 // 距离底部200px时触发加载更多
const isCloseToBottom =
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom
// 使用 ref 防止重复触发
if (isCloseToBottom && !loadingMore && hasMore && !loading && !isLoadingMoreRef.current) {
console.log('🔄 触发加载更多', {
layoutHeight: layoutMeasurement.height,
offsetY: contentOffset.y,
contentHeight: contentSize.height,
distance: contentSize.height - (layoutMeasurement.height + contentOffset.y)
})
isLoadingMoreRef.current = true
loadMore().finally(() => {
isLoadingMoreRef.current = false
})
}
}, [loadingMore, hasMore, loadMore, loading])
// 收藏Tab加载更多处理
const handleFavoritesScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
const paddingToBottom = 200
const isCloseToBottom =
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom
if (isCloseToBottom && !favoritesLoadingMore && favoritesHasMore && !favoritesLoading && !isFavoritesLoadingMoreRef.current) {
console.log('🔄 收藏Tab触发加载更多')
isFavoritesLoadingMoreRef.current = true
favoritesLoadMore().finally(() => {
isFavoritesLoadingMoreRef.current = false
})
}
}, [favoritesLoadingMore, favoritesHasMore, favoritesLoadMore, favoritesLoading])
// 点赞Tab加载更多处理
const handleLikesScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
const paddingToBottom = 200
const isCloseToBottom =
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom
if (isCloseToBottom && !likesLoadingMore && likesHasMore && !likesLoading && !isLikesLoadingMoreRef.current) {
console.log('🔄 点赞Tab触发加载更多')
isLikesLoadingMoreRef.current = true
likesLoadMore().finally(() => {
isLikesLoadingMoreRef.current = false
})
}
}, [likesLoadingMore, likesHasMore, likesLoadMore, likesLoading])
// 处理设置菜单选择
const handleSettingsSelect = async (value: string) => {
if (value === 'changePassword') {
router.push('/changePassword' as any)
} else if (value === 'language') {
// 切换语言
const newLang = i18n.language === 'zh-CN' ? 'en-US' : 'zh-CN'
i18n.changeLanguage(newLang)
} else if (value === 'logout') {
// 退出登录
console.log('🚪 点击退出登录')
const confirmText = i18n.language === 'zh-CN' ? '确定' : 'OK'
const cancelText = i18n.language === 'zh-CN' ? '取消' : 'Cancel'
const message = i18n.language === 'zh-CN' ? '确定要退出登录吗?' : 'Are you sure you want to logout?'
Toast.showActionSheet({
itemList: [message, confirmText, cancelText]
}).then(async (index) => {
// index 1 是确定按钮
if (index === 1) {
console.log('🚪 开始执行退出登录')
try {
Toast.showLoading({ title: i18n.language === 'zh-CN' ? '退出中...' : 'Logging out...' })
// 调用 better-auth 的 signOut 方法
await signOut()
Toast.hideLoading()
console.log('✅ 退出登录成功,跳转到登录页')
Toast.show(i18n.language === 'zh-CN' ? '退出登录成功' : 'Logged out successfully')
// 跳转到登录页面(注意:路由是 /auth 不是 /login
router.replace('/auth')
} catch (error) {
Toast.hideLoading()
console.error('❌ 退出登录失败:', error)
Toast.show(i18n.language === 'zh-CN' ? '退出登录失败,请稍后重试' : 'Failed to logout, please try again later')
}
}
}).catch(() => {
console.log('❌ 取消退出登录')
})
}
}
// 设置菜单选项
const getLanguageLabel = () => {
if (i18n.language === 'zh-CN') {
return t('my.languageSwitch')
} else {
return t('my.languageSwitchEn')
}
}
const settingsOptions = [
{ label: t('my.changePassword'), value: 'changePassword' },
{ label: getLanguageLabel(), value: 'language' },
{ label: t('my.logout'), value: 'logout' },
]
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 顶部积分与设置 */}
<View style={styles.topBar}>
<Pressable
style={styles.pointsPill}
onPress={() => router.push('/membership' as any)}
>
<PointsIcon />
<Text style={styles.pointsPillText}>{balance}</Text>
</Pressable>
<Dropdown
options={settingsOptions}
onSelect={(value) => handleSettingsSelect(value)}
renderTrigger={(selectedOption, isOpen, toggle) => (
<Pressable onPress={toggle}>
<SettingsIcon />
</Pressable>
)}
dropdownStyle={{
minWidth: 160,
right: 10,
backgroundColor: '#2A2A2A80',
}}
/>
</View>
{/* 个人信息区 */}
<View style={styles.profileSection}>
<Image
source={session?.user?.image ? { uri: session.user.image } : require('@/assets/images/icon.png')}
style={styles.avatar}
contentFit="cover"
/>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{profileName}</Text>
<Text style={styles.profileSubTitle}>ID {session?.user?.id?.slice(0, 8) || '--------'}</Text>
</View>
<Pressable
style={styles.editButton}
onPress={() => {
console.log('[my.tsx] Edit Profile button pressed')
setEditDrawerVisible(true)
}}
>
<Text style={styles.editButtonText}>{t('my.editProfile')}</Text>
</Pressable>
</View>
{/* Tab 导航 */}
<TabNavigation
tabs={tabs}
activeIndex={activeTabIndex}
onTabPress={handleTabPress}
/>
{/* 作品九宫格 - 包裹在手势处理器中 */}
<PanGestureHandler
onGestureEvent={handleGestureEvent}
onHandlerStateChange={handleGestureStateChange}
activeOffsetX={[-20, 20]}
>
<View style={styles.gestureContainer}>
{activeTab === 'works' && loading ? (
<MySkeleton />
) : activeTab === 'works' ? (
<ScrollView
testID="my-scroll-view"
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#9966FF"
colors={['#9966FF', '#FF6699', '#FF9966']}
progressBackgroundColor="#1C1E22"
progressViewOffset={10}
/>
}
>
<View style={styles.galleryGrid}>
{generations.map((item, index) => (
<Pressable
key={item.id}
style={[
styles.galleryItem,
index % 3 !== 2 && styles.galleryItemMarginRight,
styles.galleryItemMarginBottom,
]}
onPress={() => {
if (item.status === 'completed') {
router.push({
pathname: '/generationRecord' as any,
params: { id: item.id },
})
}
}}
disabled={item.status !== 'completed'}
>
<Image
source={getCoverUrl(item) ? { uri: getCoverUrl(item) } : require('@/assets/images/membership.png')}
style={styles.galleryImage}
contentFit="cover"
/>
{/* 遮罩:非完成状态 */}
{item.status !== 'completed' && (
<View style={styles.generatingOverlay} />
)}
{/* 数量角标:已完成且有结果 */}
{item.status === 'completed' && (item.resultUrl?.length || 0) > 0 && (
<View style={styles.counterBadge}>
<Text style={styles.counterText}>
{item.resultUrl?.length || 1}
</Text>
</View>
)}
{/* 状态角标 */}
{item.status === 'running' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>{t('my.generating')}</Text>
</View>
)}
{item.status === 'pending' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>{t('my.queuing')}</Text>
</View>
)}
</Pressable>
))}
{/* 加载更多指示器 */}
{loadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#9966FF" />
</View>
)}
{/* 空状态提示 */}
{!loading && generations.length === 0 && (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
{t('my.noWorks')}
</Text>
</View>
)}
</View>
</ScrollView>
) : activeTab === 'favorites' && favoritesLoading ? (
<MySkeleton />
) : activeTab === 'favorites' ? (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={handleFavoritesScroll}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={favoritesRefreshing}
onRefresh={onFavoritesRefresh}
tintColor="#9966FF"
colors={['#9966FF', '#FF6699', '#FF9966']}
progressBackgroundColor="#1C1E22"
progressViewOffset={10}
/>
}
>
<View style={styles.galleryGrid}>
{favorites.length === 0 && !favoritesLoading ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
{t('my.noFavorites')}
</Text>
</View>
) : (
<>
{favorites.map((item, index) => (
<Pressable
key={item.id}
style={[
styles.galleryItem,
index % 3 !== 2 && styles.galleryItemMarginRight,
styles.galleryItemMarginBottom,
]}
onPress={() => {
if (item.template?.id) {
router.push({
pathname: '/templateDetail' as any,
params: { id: item.template.id },
})
}
}}
>
<Image
source={item.template?.coverImageUrl ? { uri: item.template.coverImageUrl } : require('@/assets/images/membership.png')}
style={styles.galleryImage}
contentFit="cover"
/>
</Pressable>
))}
{/* 加载更多指示器 */}
{favoritesLoadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#9966FF" />
</View>
)}
</>
)}
</View>
</ScrollView>
) : activeTab === 'likes' && likesLoading ? (
<MySkeleton />
) : activeTab === 'likes' ? (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={handleLikesScroll}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={likesRefreshing}
onRefresh={onLikesRefresh}
tintColor="#9966FF"
colors={['#9966FF', '#FF6699', '#FF9966']}
progressBackgroundColor="#1C1E22"
progressViewOffset={10}
/>
}
>
<View style={styles.galleryGrid}>
{likes.length === 0 && !likesLoading ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
{t('my.noLikes')}
</Text>
</View>
) : (
<>
{likes.map((item, index) => (
<Pressable
key={item.id}
style={[
styles.galleryItem,
index % 3 !== 2 && styles.galleryItemMarginRight,
styles.galleryItemMarginBottom,
]}
onPress={() => {
if (item.template?.id) {
router.push({
pathname: '/templateDetail' as any,
params: { id: item.template.id },
})
}
}}
>
<Image
source={item.template?.coverImageUrl ? { uri: item.template.coverImageUrl } : require('@/assets/images/membership.png')}
style={styles.galleryImage}
contentFit="cover"
/>
</Pressable>
))}
{/* 加载更多指示器 */}
{likesLoadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#9966FF" />
</View>
)}
</>
)}
</View>
</ScrollView>
) : null}
</View>
</PanGestureHandler>
{/* 编辑资料抽屉 */}
<EditProfileDrawer
visible={editDrawerVisible}
onClose={() => setEditDrawerVisible(false)}
initialName={profileName}
initialAvatar={session?.user?.image}
onSave={(data) => {
setProfileName(data.name)
}}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
gestureContainer: {
flex: 1,
},
topBar: {
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
paddingTop: 19,
paddingRight: 16,
flexDirection: 'row',
justifyContent: 'flex-end',
backgroundColor: '#090A0B',
gap: 12,
},
pointsPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 1,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 100,
backgroundColor: '#1C1E22',
},
pointsPillText: {
color: '#FFCF00',
fontSize: 12,
fontWeight: '600',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
paddingBottom: 100,
},
profileSection: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 16,
paddingRight: 16,
marginTop: 20,
marginBottom: 32,
backgroundColor: '#090A0B',
},
avatar: {
width: 64,
height: 64,
borderRadius: 32,
overflow: 'hidden',
marginRight: 16,
},
profileInfo: {
flex: 1,
},
profileName: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
profileSubTitle: {
color: '#FFFFFF',
fontSize: 12,
opacity: 0.7,
},
editButton: {
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: '#1C1E22',
},
editButtonText: {
color: '#FFFFFF',
fontSize: 12,
},
galleryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 10,
},
galleryItem: {
width: GALLERY_ITEM_SIZE,
aspectRatio: 1,
overflow: 'hidden',
backgroundColor: '#1C1E22',
position: 'relative',
},
galleryItemMarginRight: {
marginRight: GALLERY_GAP,
},
galleryItemMarginBottom: {
marginBottom: GALLERY_GAP,
},
galleryImage: {
width: '100%',
height: undefined,
aspectRatio: 1,
},
generatingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#00000080',
},
counterBadge: {
position: 'absolute',
right: 8,
bottom: 8,
paddingHorizontal: 10,
paddingVertical: 1,
borderRadius: 6,
backgroundColor: '#16181B1A',
},
counterText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
generatingBadge: {
position: 'absolute',
left: 8,
bottom: 8,
},
generatingBadgeText: {
color: '#F5F5F5',
fontSize: 9,
fontWeight: '500',
},
loadingMoreContainer: {
width: '100%',
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
emptyState: {
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateText: {
color: '#FFFFFF80',
fontSize: 14,
},
})