feat: 优化数据请求逻辑,添加页面聚焦时重新请求数据的功能;重构相关代码
This commit is contained in:
parent
30d06355a3
commit
a87bc6328c
|
|
@ -1,6 +1,5 @@
|
|||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
|
||||
import Img from '@share/components/Img'
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { router, useFocusEffect } from 'expo-router'
|
||||
|
|
@ -79,7 +78,7 @@ const Index = observer(function Index() {
|
|||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
const transformTemplateToMediaItem = (template: TemplateData): MediaItem => {
|
||||
const transformTemplateToMediaItem = useCallback((template: TemplateData): MediaItem => {
|
||||
const isVideo = template.previewUrl?.includes('.mp4')
|
||||
return {
|
||||
id: template.id,
|
||||
|
|
@ -89,7 +88,7 @@ const Index = observer(function Index() {
|
|||
likeCount: template.likeCount || 0,
|
||||
price: template.price || 2,
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** ================= 统一请求函数 ================= */
|
||||
|
||||
|
|
@ -136,7 +135,54 @@ const Index = observer(function Index() {
|
|||
setHasMore(false)
|
||||
}
|
||||
|
||||
setAllItems((prev) => (mode === 'loadMore' ? [...prev, ...newItems] : newItems))
|
||||
const updatedItems = mode === 'loadMore' ? [...allItems, ...newItems] : newItems
|
||||
|
||||
// 检测重复的 id 并打印
|
||||
const checkDuplicateItems = (items: MediaItem[]) => {
|
||||
const idMap = new Map<string, MediaItem[]>()
|
||||
|
||||
// 将items按id分组
|
||||
items.forEach((item) => {
|
||||
if (!idMap.has(item.id)) {
|
||||
idMap.set(item.id, [])
|
||||
}
|
||||
idMap.get(item.id)!.push(item)
|
||||
})
|
||||
|
||||
// 找出有重复id的项目
|
||||
const duplicates: { id: string; items: MediaItem[] }[] = []
|
||||
idMap.forEach((itemsWithSameId, id) => {
|
||||
if (itemsWithSameId.length > 1) {
|
||||
duplicates.push({ id, items: itemsWithSameId })
|
||||
}
|
||||
})
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
console.log('🚨 发现重复的items:')
|
||||
duplicates.forEach(({ id, items }) => {
|
||||
console.log(`ID: ${id} 重复了 ${items.length} 次:`)
|
||||
items.forEach((item, index) => {
|
||||
console.log(` [${index + 1}]`, {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
likeCount: item.likeCount,
|
||||
price: item.price,
|
||||
})
|
||||
})
|
||||
console.log('---')
|
||||
})
|
||||
} else {
|
||||
console.log('✅ 没有发现重复的items')
|
||||
}
|
||||
|
||||
return duplicates
|
||||
}
|
||||
|
||||
// 检测并打印重复项
|
||||
checkDuplicateItems(updatedItems)
|
||||
|
||||
setAllItems(updatedItems)
|
||||
} catch (e) {
|
||||
console.error('fetchList error:', e)
|
||||
setHasMore(false)
|
||||
|
|
@ -154,7 +200,7 @@ const Index = observer(function Index() {
|
|||
|
||||
/** ================= tab 切换(抽象后的重点) ================= */
|
||||
|
||||
const changeTab = (tab: ActiveTab) => {
|
||||
const changeTab = useCallback((tab: ActiveTab) => {
|
||||
queryRef.current = {
|
||||
...queryRef.current,
|
||||
tab,
|
||||
|
|
@ -163,11 +209,11 @@ const Index = observer(function Index() {
|
|||
setHasMore(true)
|
||||
firstLoadDoneRef.current = false
|
||||
fetchList('init')
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** ================= 搜索 ================= */
|
||||
|
||||
const handleSearch = (text: string) => {
|
||||
const handleSearch = useCallback((text: string) => {
|
||||
queryRef.current = {
|
||||
...queryRef.current,
|
||||
search: text,
|
||||
|
|
@ -176,18 +222,18 @@ const Index = observer(function Index() {
|
|||
setHasMore(true)
|
||||
firstLoadDoneRef.current = false
|
||||
fetchList('refresh')
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** ================= 刷新 / 加载更多 ================= */
|
||||
|
||||
const handleRefresh = () => {
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryRef.current.page = 1
|
||||
setHasMore(true)
|
||||
firstLoadDoneRef.current = false
|
||||
fetchList('refresh')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const handleLoadMore = useCallback(() => {
|
||||
// 防止重复请求或首次渲染时误触发
|
||||
if (loadingRef.current || !hasMore || refreshing || loadingMore) return
|
||||
if (!firstLoadDoneRef.current) return
|
||||
|
|
@ -196,7 +242,7 @@ const Index = observer(function Index() {
|
|||
queryRef.current.page += 1
|
||||
fetchList('loadMore')
|
||||
console.log('handleLoadMore-------------')
|
||||
}
|
||||
}, [hasMore, refreshing, loadingMore])
|
||||
|
||||
/** ================= selectedItem 修正 ================= */
|
||||
|
||||
|
|
@ -262,17 +308,25 @@ const Index = observer(function Index() {
|
|||
Toast.show({ title: '请先选择一个模板' })
|
||||
return
|
||||
}
|
||||
if (balance < selectedItem.price) {
|
||||
Toast.show({ title: '余额不足,请充值' })
|
||||
|
||||
// 显示加载状态并刷新余额
|
||||
Toast.showLoading()
|
||||
try {
|
||||
await userBalanceStore.load(true) // 生成前刷新余额
|
||||
|
||||
// 使用最新的余额数据进行检查
|
||||
const currentBalance = userBalanceStore.balance
|
||||
if (currentBalance < selectedItem.price) {
|
||||
Toast.show({ title: '余额不足,请充值' })
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.show({ title: '余额加载失败,请重试' })
|
||||
return
|
||||
} finally {
|
||||
Toast.hideLoading()
|
||||
}
|
||||
|
||||
await userBalanceStore.load(true) // 生成前刷新余额
|
||||
// 如果余额仍不足,提示充值
|
||||
if (balance < selectedItem.price) {
|
||||
Toast.show({ title: '余额不足,请充值' })
|
||||
return
|
||||
}
|
||||
Toast.showModal(
|
||||
<ConfirmModal
|
||||
content={
|
||||
|
|
@ -284,21 +338,33 @@ const Index = observer(function Index() {
|
|||
onCancel={Toast.hideModal}
|
||||
onConfirm={async () => {
|
||||
Toast.hideModal()
|
||||
// 先进行乐观更新,扣减余额
|
||||
userBalanceStore.deductBalance(selectedItem.price)
|
||||
Toast.showLoading()
|
||||
|
||||
const { generationId, error } = await runTemplate({
|
||||
templateId: selectedItem.id,
|
||||
data: {},
|
||||
originalUrl: selectedItem.url,
|
||||
})
|
||||
if (generationId && user?.id) {
|
||||
// 生成成功后强制刷新余额以获取准确数据
|
||||
userBalanceStore.load(true)
|
||||
} else {
|
||||
// 生成失败,恢复余额
|
||||
try {
|
||||
// 先进行乐观更新,扣减余额
|
||||
userBalanceStore.deductBalance(selectedItem.price)
|
||||
|
||||
const { generationId, error } = await runTemplate({
|
||||
templateId: selectedItem.id,
|
||||
data: {},
|
||||
originalUrl: selectedItem.url,
|
||||
})
|
||||
|
||||
if (generationId && user?.id) {
|
||||
// 生成成功后强制刷新余额以获取准确数据
|
||||
await userBalanceStore.load(true)
|
||||
Toast.show({ title: '生成任务开启,请在我的生成中查看' })
|
||||
} else {
|
||||
// 生成失败,恢复余额
|
||||
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
|
||||
Toast.show({ title: error?.message || '生成失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
// 异常情况下恢复余额
|
||||
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
|
||||
Toast.show({ title: error?.message || '生成失败' })
|
||||
Toast.show({ title: '网络异常,请重试' })
|
||||
} finally {
|
||||
Toast.hideLoading()
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
|
|
@ -329,6 +395,21 @@ const Index = observer(function Index() {
|
|||
}
|
||||
}
|
||||
|
||||
// 缓存渲染函数以提升性能
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: MediaItem }) => (
|
||||
<GridItem
|
||||
isSelected={selectedItem?.id === item.id}
|
||||
item={item}
|
||||
itemWidth={ITEM_WIDTH}
|
||||
onSelect={() => setSelectedItem(item)}
|
||||
/>
|
||||
),
|
||||
[selectedItem?.id],
|
||||
)
|
||||
|
||||
const keyExtractor = useCallback((item: MediaItem) => item.id, [])
|
||||
|
||||
/** ================= UI ================= */
|
||||
return (
|
||||
<Block className="flex-1 bg-black px-[12px]">
|
||||
|
|
@ -368,14 +449,8 @@ const Index = observer(function Index() {
|
|||
drawDistance={1200}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={<RefreshControl colors={['#FFE500']} refreshing={refreshing} onRefresh={handleRefresh} />}
|
||||
renderItem={({ item }) => (
|
||||
<GridItem
|
||||
isSelected={selectedItem?.id === item.id}
|
||||
item={item}
|
||||
itemWidth={ITEM_WIDTH}
|
||||
onSelect={() => setSelectedItem(item)}
|
||||
/>
|
||||
)}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={renderListEmpty}
|
||||
showsVerticalScrollIndicator={false}
|
||||
data={allItems}
|
||||
|
|
@ -398,33 +473,29 @@ type SearchOverlayProps = {
|
|||
const SearchOverlay = memo<SearchOverlayProps>(function SearchOverlay({ isOpen, onChange, onClose, onSearch }) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const handleTextChange = (text: string) => {
|
||||
setSearchText(text)
|
||||
onChange && onChange(text)
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
const handleTextChange = useCallback(
|
||||
(text: string) => {
|
||||
setSearchText(text)
|
||||
onChange?.(text)
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSearch(text)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 删除定时器
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
// 增加防抖时间到500ms,减少频繁搜索
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSearch(text)
|
||||
}, 500)
|
||||
},
|
||||
[onChange, onSearch],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearchText('')
|
||||
onSearch('')
|
||||
onClose && onClose()
|
||||
}
|
||||
onClose?.()
|
||||
}, [onClose, onSearch])
|
||||
|
||||
if (!isOpen) return null
|
||||
return (
|
||||
|
|
@ -516,11 +587,7 @@ const HeroCircle = memo<HeroCircleProps>(function HeroCircle({ selectedItem, onQ
|
|||
<Block className="relative mx-[12px] flex-row justify-between">
|
||||
<Block className="relative z-10 mt-[20px] size-[224px] rounded-full border-4 border-black bg-white shadow-deep-black">
|
||||
<Block className="relative size-full overflow-hidden rounded-full bg-black">
|
||||
{selectedItem.type === 'video' ? (
|
||||
<VideoBox style={{ height: 216, width: 216, borderRadius: 108 }} url={selectedItem.url} />
|
||||
) : (
|
||||
<Img className="size-full" src={selectedItem.url} />
|
||||
)}
|
||||
<VideoBox style={{ height: 216, width: 216, borderRadius: 108 }} url={selectedItem.url} />
|
||||
|
||||
<Block className="absolute inset-0">
|
||||
<LinearGradient
|
||||
|
|
@ -605,72 +672,85 @@ type GridItemProps = {
|
|||
itemWidth: number
|
||||
onSelect: () => void
|
||||
}
|
||||
const GridItem = memo<GridItemProps>(function GridItem({ item, isSelected, itemWidth, onSelect }) {
|
||||
// console.log('item-------------', item);
|
||||
|
||||
return (
|
||||
<Block className="relative mb-[12px]" onClick={onSelect}>
|
||||
<Block
|
||||
className={`relative overflow-hidden border-2 ${isSelected ? 'border-accent' : 'border-black'}`}
|
||||
style={{
|
||||
transform: [{ skewX: '-6deg' }],
|
||||
height: itemWidth,
|
||||
width: itemWidth,
|
||||
}}
|
||||
>
|
||||
<VideoBox style={{ height: itemWidth, width: itemWidth }} url={item.url} />
|
||||
|
||||
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
|
||||
// 优化GridItem组件,减少不必要的重渲染
|
||||
const GridItem = memo<GridItemProps>(
|
||||
function GridItem({ item, isSelected, itemWidth, onSelect }) {
|
||||
// console.log('item-------------', item);
|
||||
|
||||
return (
|
||||
<Block className="relative mb-[12px]" onClick={onSelect}>
|
||||
<Block
|
||||
className={cn(
|
||||
'absolute left-[0px] top-[0px] z-[20] border-b-[2px] border-r-[2px] border-black bg-black px-[6px] py-[2px]',
|
||||
{
|
||||
'bg-accent': isSelected,
|
||||
},
|
||||
)}
|
||||
className={`relative overflow-hidden border-2 ${isSelected ? 'border-accent' : 'border-black'}`}
|
||||
style={{
|
||||
transform: [{ skewX: '-6deg' }],
|
||||
height: itemWidth,
|
||||
width: itemWidth,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={cn('font-900 text-[8px] text-white', {
|
||||
'text-black': isSelected,
|
||||
})}
|
||||
<VideoBox style={{ height: itemWidth, width: itemWidth }} url={item.url} />
|
||||
|
||||
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
|
||||
|
||||
<Block
|
||||
className={cn(
|
||||
'absolute left-[0px] top-[0px] z-[20] border-b-[2px] border-r-[2px] border-black bg-black px-[6px] py-[2px]',
|
||||
{
|
||||
'bg-accent': isSelected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{item.id}
|
||||
</Text>
|
||||
</Block>
|
||||
<Text
|
||||
className={cn('font-900 text-[8px] text-white', {
|
||||
'text-black': isSelected,
|
||||
})}
|
||||
>
|
||||
{item.id}
|
||||
</Text>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
className="absolute inset-x-0 bottom-0 z-20 items-end justify-between p-[4px] pt-[16px]"
|
||||
style={{ transform: [{ skewX: '6deg' }] }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.8)', 'transparent']}
|
||||
end={{ x: 0, y: 0 }}
|
||||
start={{ x: 0, y: 1 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
<Block
|
||||
className="absolute inset-x-0 bottom-0 z-20 items-end justify-between p-[4px] pt-[16px]"
|
||||
style={{ transform: [{ skewX: '6deg' }] }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.8)', 'transparent']}
|
||||
end={{ x: 0, y: 0 }}
|
||||
start={{ x: 0, y: 1 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Block className="w-full flex-1 flex-row items-center justify-between px-[4px]">
|
||||
<Block className="">
|
||||
<Text className="font-900 max-w-[40px] -skew-x-6 text-[7px] text-white" numberOfLines={1}>
|
||||
{item.id}
|
||||
</Text>
|
||||
</Block>
|
||||
<Block className="-skew-x-6 flex-row items-center gap-[2px]">
|
||||
<Ionicons color="#FF0000" name="heart" size={10} />
|
||||
<Text className="font-900 text-[8px] text-white">{item.likeCount}</Text>
|
||||
<Block className="w-full flex-1 flex-row items-center justify-between px-[4px]">
|
||||
<Block className="">
|
||||
<Text className="font-900 max-w-[40px] -skew-x-6 text-[7px] text-white" numberOfLines={1}>
|
||||
{item.id}
|
||||
</Text>
|
||||
</Block>
|
||||
<Block className="-skew-x-6 flex-row items-center gap-[2px]">
|
||||
<Ionicons color="#FF0000" name="heart" size={10} />
|
||||
<Text className="font-900 text-[8px] text-white">{item.likeCount}</Text>
|
||||
</Block>
|
||||
</Block>
|
||||
</Block>
|
||||
</Block>
|
||||
</Block>
|
||||
</Block>
|
||||
)
|
||||
})
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// 自定义比较函数,只在关键属性变化时才重渲染
|
||||
return (
|
||||
prevProps.item.id === nextProps.item.id &&
|
||||
prevProps.isSelected === nextProps.isSelected &&
|
||||
prevProps.itemWidth === nextProps.itemWidth &&
|
||||
prevProps.item.likeCount === nextProps.item.likeCount
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default Index
|
||||
|
|
|
|||
|
|
@ -1,373 +0,0 @@
|
|||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
|
||||
import Img from '@share/components/Img'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { router } from 'expo-router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Dimensions, ScrollView } from 'react-native'
|
||||
|
||||
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
|
||||
import { type TemplateGeneration, useTemplateGenerations } from '@/hooks/data/use-template-generations'
|
||||
import { userBalanceStore, userStore } from '@/stores'
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
const My = observer(function My() {
|
||||
// 从MobX Store获取用户信息
|
||||
const { user, isLoading: authLoading, signOut } = userStore
|
||||
const { data: generationsData, loading: generationsLoading, load: loadGenerations } = useTemplateGenerations()
|
||||
const { batchDeleteGenerations, loading: deleteLoading } = useTemplateActions()
|
||||
|
||||
// 使用MobX Store中的余额信息
|
||||
const { balance } = userBalanceStore
|
||||
|
||||
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 })
|
||||
userBalanceStore.load(true) // 加载用户余额
|
||||
}
|
||||
}, [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-0 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-0 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-4 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="size-[96px] border-[3px] border-black bg-accent p-[4px] shadow-soft-black-10">
|
||||
<Img src={avatarUrl} className="size-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-6 text-[24px] tracking-[-0.5px] text-black" numberOfLines={1}>
|
||||
{username}
|
||||
</Text>
|
||||
<Block onClick={handleLogout} className="border-2 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] text-white">
|
||||
UID: {uid}
|
||||
</Text>
|
||||
<Text className="font-900 border-2 border-black bg-accent px-[8px] py-[2px] text-[10px] text-black shadow-small-accent">
|
||||
CREDITS: {balance}
|
||||
</Text>
|
||||
</Block>
|
||||
<Block className="mt-[12px] flex-row items-center gap-[16px] border-t-2 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-px z-20 rotate-45 border-y-2 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] 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-2 border-black px-[16px] py-[4px] ${isActive ? 'bg-black' : 'bg-white'} -skew-x-12`}
|
||||
>
|
||||
<Text className={`font-900 text-[10px] ${isActive ? 'text-accent' : 'text-black'} skew-x-12`}>
|
||||
{label}
|
||||
</Text>
|
||||
</Block>
|
||||
)
|
||||
})}
|
||||
</Block>
|
||||
<Block
|
||||
onClick={toggleSelectionMode}
|
||||
className={`border-2 border-black px-[16px] py-[6px] shadow-medium-black ${isSelectionMode ? 'bg-[#e61e25] text-white' : 'bg-accent text-black'}`}
|
||||
>
|
||||
<Text className="font-900 text-[12px]">{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] 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-2 ${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="size-full" />
|
||||
)
|
||||
) : (
|
||||
<Block className="size-full items-center justify-center bg-gray-200">
|
||||
<Ionicons name="image-outline" size={32} color="#999" />
|
||||
</Block>
|
||||
)}
|
||||
</Block>
|
||||
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
|
||||
|
||||
{isSelectionMode && (
|
||||
<Block
|
||||
className="absolute inset-0 z-30 items-center justify-center"
|
||||
style={{ transform: [{ skewX: '6deg' }] }}
|
||||
>
|
||||
<Block
|
||||
className={`size-[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-0 top-0 z-20 border-b-2 border-r-2 border-black bg-accent px-[6px] py-[2px]">
|
||||
<Text className="font-900 text-[8px] text-black">{statusRank}</Text>
|
||||
</Block>
|
||||
)}
|
||||
{!isSelectionMode && (
|
||||
<Block
|
||||
className="absolute inset-x-0 bottom-0 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-6 text-[7px] text-white" numberOfLines={1}>
|
||||
{generation.type}
|
||||
</Text>
|
||||
<Block className="-skew-x-6 flex-row items-center gap-[2px]">
|
||||
<Ionicons name="time-outline" size={10} color="#FFE500" />
|
||||
<Text className="font-900 text-[8px] 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 inset-x-[16px] bottom-[96px] z-50">
|
||||
<Block className="-skew-x-3 flex-row items-center justify-between border-[3px] border-black bg-white p-[12px] shadow-large-black">
|
||||
<Block className="skew-x-3 flex-row items-center gap-[12px] pl-[8px]">
|
||||
<Text className="font-900 text-[14px]">已选: {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-3 flex-row items-center gap-[8px] border-2 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] 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>
|
||||
)
|
||||
})
|
||||
|
||||
export default My
|
||||
|
|
@ -3,7 +3,7 @@ import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
|
|||
import Img from '@share/components/Img'
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
import { router } from 'expo-router'
|
||||
import { router, useFocusEffect } from 'expo-router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ActivityIndicator, RefreshControl, ScrollView, View } from 'react-native'
|
||||
|
|
@ -68,6 +68,16 @@ const Sync = observer(() => {
|
|||
}
|
||||
}, [user?.id, loadGenerations])
|
||||
|
||||
// 页面聚焦时重新请求数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (user?.id) {
|
||||
console.log('页面聚焦,重新请求数据')
|
||||
loadGenerations()
|
||||
}
|
||||
}, [user?.id, loadGenerations]),
|
||||
)
|
||||
|
||||
// 将生成记录转换为 posts 格式
|
||||
const posts = useMemo(() => {
|
||||
const generations = generationsData?.data || []
|
||||
|
|
|
|||
Loading…
Reference in New Issue