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

350 lines
16 KiB
TypeScript

import React, { useMemo, useState, useCallback, useEffect } from 'react'
import { Block, Text, Toast, ConfirmModal, VideoBox } from '@share/components'
import Img from '@share/components/Img'
import { Ionicons } from '@expo/vector-icons'
import { Dimensions, ScrollView } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { useAuth } from '@/hooks/core/use-auth'
import { useTemplateGenerations, TemplateGeneration } from '@/hooks/data/use-template-generations'
import { useUserBalance } from '@/hooks/core/use-user-balance'
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
import { router } from 'expo-router'
const BACKGROUND_VIDEOS = [
'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4',
'https://cdn.roasmax.cn/material/992e6c5d940c42feb71c27e556b754c0.mp4',
'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4',
]
function isVideoUrl(url: string) {
return url.endsWith('.mp4')
}
export default function My() {
const { user, isLoading: authLoading, signOut } = useAuth()
const { data: generationsData, loading: generationsLoading, load: loadGenerations } = useTemplateGenerations()
const { balance, load: loadBalance } = useUserBalance()
const { batchDeleteGenerations, loading: deleteLoading } = useTemplateActions()
const [activeFilter, setActiveFilter] = useState<'my_gen' | 'my_album'>('my_gen')
const [bgVideo] = useState(() => BACKGROUND_VIDEOS[Math.floor(Math.random() * BACKGROUND_VIDEOS.length)])
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
useEffect(() => {
if (user?.id) {
loadGenerations({ userId: user.id })
loadBalance(user.id)
}
}, [user?.id])
const generations = useMemo(() => generationsData?.data || [], [generationsData])
const filteredPosts = activeFilter === 'my_gen' ? generations : generations.filter((g: TemplateGeneration) => g.status === 'completed')
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((v) => !v)
setSelectedIds(new Set())
}, [])
const handleItemClick = useCallback((generation: any) => {
if (isSelectionMode) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(generation.id)) next.delete(generation.id)
else next.add(generation.id)
return next
})
}
}, [isSelectionMode])
const handleDelete = useCallback(async () => {
if (selectedIds.size === 0) return
const { success } = await batchDeleteGenerations(Array.from(selectedIds))
if (success && user?.id) {
await loadGenerations({ userId: user.id })
setSelectedIds(new Set())
setIsSelectionMode(false)
}
}, [selectedIds, batchDeleteGenerations, user?.id, loadGenerations])
const selectAll = useCallback(() => {
if (selectedIds.size === filteredPosts.length) setSelectedIds(new Set())
else setSelectedIds(new Set(filteredPosts.map((p: TemplateGeneration) => p.id)))
}, [selectedIds.size, filteredPosts])
const handleLogout = useCallback(() => {
Toast.showModal(
<ConfirmModal
title="确认退出登录?"
badge="LOGOUT"
content="退出登录后需要重新登录才能使用完整功能"
confirmText="确认退出"
cancelText="取消"
onConfirm={async () => {
Toast.hideModal()
await signOut()
router.replace('/auth')
}}
onCancel={() => Toast.hideModal()}
/>,
{},
)
}, [signOut])
const { width: screenWidth } = Dimensions.get('window')
const itemWidth = Math.floor((screenWidth - 24 - 12 * 2) / 3)
const renderBanner = useCallback(() => (
<Block className="absolute inset-[0px] z-[0] overflow-hidden">
<VideoBox url={bgVideo} style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, opacity: 0.6 }} />
<Block className="absolute inset-[0px] bg-black/10" />
</Block>
), [bgVideo])
const renderHeaderCard = useCallback(() => {
const username = user?.name || user?.email || 'Guest'
const avatarUrl = user?.image || 'https://image.pollinations.ai/prompt/cool%20anime%20boy%20avatar%20hoodie?seed=123&nologo=true'
const uid = user?.id?.slice(-6) || '000000'
const generationCount = generations.length
const completedCount = generations.filter((g: TemplateGeneration) => g.status === 'completed').length
return (
<Block className="relative mb-[24px] mt-[16px] px-[12px]">
<Block className="relative overflow-hidden border-[4px] border-black bg-white p-[16px] shadow-deep-black">
<Block style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, opacity: 0.1 }} />
<Block className="relative z-[10] flex-row items-center gap-[16px]">
<Block className="h-[96px] w-[96px] border-[3px] border-black bg-accent p-[4px] shadow-soft-black-10">
<Img
src={avatarUrl}
className="h-full w-full border border-black"
/>
</Block>
<Block className="flex-1">
<Block className="mb-[4px] flex-row items-center justify-between">
<Text className="font-900 skew-x-[-6deg] text-[24px] italic tracking-[-0.5px] text-black" numberOfLines={1}>
{username}
</Text>
<Block onClick={handleLogout} className="border-[2px] border-black bg-black p-[8px] shadow-medium-gray">
<Ionicons name="log-out-outline" size={18} color="white" />
</Block>
</Block>
<Block className="flex-row items-center gap-[8px]">
<Text className="font-900 border border-black bg-black px-[8px] py-[2px] text-[10px] italic text-white">
UID: {uid}
</Text>
<Text className="font-900 border-[2px] border-black bg-accent px-[8px] py-[2px] text-[10px] italic text-black shadow-small-accent">
CREDITS: {balance}
</Text>
</Block>
<Block className="mt-[12px] flex-row items-center gap-[16px] border-t-[2px] border-black pt-[8px]">
<Block>
<Text className="font-700 text-[10px] text-gray-500">TOTAL</Text>
<Text className="font-900 text-[14px] text-black">{generationCount}</Text>
</Block>
<Block>
<Text className="font-700 text-[10px] text-gray-500">COMPLETED</Text>
<Text className="font-900 text-[14px] text-black">{completedCount}</Text>
</Block>
<Block>
<Text className="font-700 text-[10px] text-gray-500">CREDITS</Text>
<Text className="font-900 text-[14px] text-black">{balance}</Text>
</Block>
</Block>
</Block>
</Block>
{balance > 100 && (
<Block className="absolute right-[-32px] top-[1px] z-[20] rotate-[45deg] border-y-[2px] border-black bg-[#e61e25] px-[40px] py-[4px]">
<Text className="font-900 text-[12px] text-white">PRO</Text>
</Block>
)}
</Block>
</Block>
)
}, [user, balance, generations])
const renderActions = useCallback(() => (
<Block className="mb-[24px] flex-row gap-[12px] px-[12px]">
{[
{ label: 'SHOP', color: '#4ADE80', icon: 'bag-outline' as const },
{ label: 'SYNC', color: '#FFE500', icon: 'watch-outline' as const },
{ label: 'PAY', color: '#e61e25', icon: 'card-outline' as const },
].map(({ label, color, icon }) => (
<Block key={label} className="relative flex-1 items-center justify-center border-[3px] border-black bg-white" style={{ height: 56 }}>
<Ionicons name={icon} size={20} color="black" />
<Text className="font-900 text-[10px] italic text-black">{label}</Text>
</Block>
))}
</Block>
), [])
const renderFilters = useCallback(() => (
<Block className="mb-[12px] flex-row items-center justify-between px-[12px]">
<Block className="flex-row gap-[8px]">
{['我的生成', '我的专辑'].map((label) => {
const target = label === '我的生成' ? 'my_gen' : 'my_album'
const isActive = activeFilter === (target as 'my_gen' | 'my_album')
return (
<Block
key={label}
onClick={() => setActiveFilter(target as 'my_gen' | 'my_album')}
className={`border-[2px] border-black px-[16px] py-[4px] ${isActive ? 'bg-black' : 'bg-white'} skew-x-[-12deg]`}
>
<Text className={`font-900 text-[10px] italic ${isActive ? 'text-accent' : 'text-black'} skew-x-[12deg]`}>{label}</Text>
</Block>
)
})}
</Block>
<Block
onClick={toggleSelectionMode}
className={`border-[2px] border-black px-[16px] py-[6px] shadow-medium-black ${isSelectionMode ? 'bg-[#e61e25] text-white' : 'bg-accent text-black'}`}
>
<Text className="font-900 text-[12px] italic">{isSelectionMode ? '取消' : '管理'}</Text>
</Block>
</Block>
), [activeFilter, isSelectionMode, toggleSelectionMode])
const renderGrid = useCallback(() => {
if (filteredPosts.length === 0) {
return (
<Block className="items-center justify-center px-[12px] py-[80px]">
<Block className="items-center gap-[16px] border-[3px] border-black bg-white p-[32px] shadow-large-black">
<Ionicons name="images-outline" size={64} color="#cccccc" />
<Text className="font-900 text-[16px] italic text-gray-500">
{activeFilter === 'my_gen' ? '暂无生成作品' : '暂无收藏'}
</Text>
</Block>
</Block>
)
}
return (
<Block className="px-[12px]">
<Block className="flex-row flex-wrap gap-[12px]">
{filteredPosts.map((generation: TemplateGeneration) => {
const isSelected = selectedIds.has(generation.id)
const imageUrl = generation.resultUrl?.[0] || generation.originalUrl || ''
const statusRank = generation.status === 'completed' ? 'S' : generation.status === 'processing' ? 'A' : 'B'
return (
<Block key={generation.id} onClick={() => handleItemClick(generation)} className="relative">
<Block
className={`relative overflow-hidden border-[2px] ${isSelected ? 'shadow-[0px_0px_0px_4px_#FFE500]' : 'shadow-hard-black'} ${isSelected ? 'border-accent' : 'border-black'}`}
style={{ transform: [{ skewX: '-6deg' }], height: itemWidth, width: itemWidth }}
>
<Block style={{ transform: [{ skewX: '6deg' }], height: itemWidth, width: itemWidth }}>
{imageUrl ? (
isVideoUrl(imageUrl) ? (
<VideoBox url={`${imageUrl}#t=0.1`} style={{ height: itemWidth, width: itemWidth }} />
) : (
<Img src={imageUrl} className="h-full w-full" />
)
) : (
<Block className="h-full w-full items-center justify-center bg-gray-200">
<Ionicons name="image-outline" size={32} color="#999" />
</Block>
)}
</Block>
{isSelected && <Block className="absolute inset-[0px] border-[3px] border-accent" />}
{isSelectionMode && (
<Block className="absolute inset-[0px] z-[30] items-center justify-center" style={{ transform: [{ skewX: '6deg' }] }}>
<Block
className={`h-[32px] w-[32px] items-center justify-center rounded-full border-[3px] border-black ${isSelected ? 'bg-accent' : 'bg-white/50'}`}
>
<Ionicons name="checkmark" size={20} color="black" />
</Block>
</Block>
)}
{!isSelectionMode && (
<Block className="absolute left-[0px] top-[0px] z-[20] border-b-[2px] border-r-[2px] border-black bg-accent px-[6px] py-[2px]">
<Text className="font-900 text-[8px] text-black">{statusRank}</Text>
</Block>
)}
{!isSelectionMode && (
<Block
className="absolute bottom-[0px] left-[0px] right-[0px] z-[20] items-end justify-between p-[4px] pt-[16px]"
style={{ transform: [{ skewX: '6deg' }] }}
>
<LinearGradient
colors={['rgba(0,0,0,0.8)', 'transparent']}
start={{ x: 0, y: 1 }}
end={{ x: 0, y: 0 }}
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
/>
<Text className="font-900 max-w-[40px] skew-x-[-6deg] text-[7px] italic text-white" numberOfLines={1}>
{generation.type}
</Text>
<Block className="skew-x-[-6deg] flex-row items-center gap-[2px]">
<Ionicons name="time-outline" size={10} color="#FFE500" />
<Text className="font-900 text-[8px] italic text-white">
{new Date(generation.createdAt).toLocaleDateString()}
</Text>
</Block>
</Block>
)}
</Block>
</Block>
)
})}
</Block>
<Block className="h-[200px] w-full" />
</Block>
)
}, [filteredPosts, selectedIds, isSelectionMode, handleItemClick, activeFilter, itemWidth])
const renderSelection = useCallback(() => {
if (!isSelectionMode) return null
return (
<Block className="absolute bottom-[96px] left-[16px] right-[16px] z-[50]">
<Block className="skew-x-[-3deg] flex-row items-center justify-between border-[3px] border-black bg-white p-[12px] shadow-large-black">
<Block className="skew-x-[3deg] flex-row items-center gap-[12px] pl-[8px]">
<Text className="font-900 text-[14px] italic">: {selectedIds.size}</Text>
<Block onClick={selectAll} className="text-[12px] underline">
<Text className="font-700 text-[12px]"></Text>
</Block>
</Block>
<Block
onClick={handleDelete}
className={`font-900 skew-x-[3deg] transform flex-row items-center gap-[8px] border-[2px] border-black px-[16px] py-[8px] text-[14px] shadow-medium-black ${selectedIds.size > 0 && !deleteLoading ? 'bg-[#e61e25]' : 'bg-gray-200'}`}
>
{deleteLoading ? (
<Ionicons name="hourglass-outline" size={16} color="white" />
) : (
<Ionicons name="trash-outline" size={16} color="white" />
)}
<Text className="font-900 text-[14px] text-white">
{deleteLoading ? '删除中...' : '删除'}
</Text>
</Block>
</Block>
</Block>
)
}, [isSelectionMode, selectedIds.size, selectAll, handleDelete, deleteLoading])
if (authLoading || generationsLoading) {
return (
<Block className="relative flex-1 items-center justify-center bg-black">
{renderBanner()}
<Block className="relative z-[10] items-center gap-[16px] border-[3px] border-accent bg-white p-[32px] shadow-large-accent">
<Ionicons name="hourglass-outline" size={48} color="#FFE500" />
<Text className="font-900 text-[16px] italic text-black">...</Text>
</Block>
</Block>
)
}
return (
<Block className="relative flex-1 bg-black text-white">
{renderBanner()}
<ScrollView className="relative z-[10] flex-1">
{renderHeaderCard()}
{renderActions()}
{renderFilters()}
{renderGrid()}
</ScrollView>
{renderSelection()}
</Block>
)
}