761 lines
27 KiB
TypeScript
761 lines
27 KiB
TypeScript
/* eslint-disable react/display-name */
|
|
import React, { useMemo, useState, useEffect, useRef, memo, useCallback } from 'react'
|
|
import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
|
|
import Img from '@share/components/Img'
|
|
import { AntDesign, FontAwesome, Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'
|
|
import * as ImagePicker from 'expo-image-picker'
|
|
import { Dimensions, ScrollView, View } from 'react-native'
|
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { imgPicker } from '@/@share/apis'
|
|
import { useBleExplorer } from '@/ble'
|
|
import { cn } from '@/utils/cn'
|
|
import { screenHeight, screenWidth } from '@/utils'
|
|
import { FlashList } from '@shopify/flash-list'
|
|
import { useTemplateGenerations } from '@/hooks/data/use-template-generations'
|
|
import { useFileUpload } from '@/hooks/actions/use-file-upload'
|
|
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
|
|
import { useAuth } from '@/hooks/core/use-auth'
|
|
import { aniStorage } from '@/utils/aniStorage'
|
|
import { get } from 'react-native/Libraries/TurboModule/TurboModuleRegistry'
|
|
|
|
// ============ 常量定义 ============
|
|
const BACKGROUND_VIDEOS = [
|
|
'https://cdn.roasmax.cn/upload/529c4a4ff1db46a9bd9b4da9af1e7eae.mp4',
|
|
'https://cdn.roasmax.cn/upload/63c9f4a73a354403bb33f680d7939538.mp4',
|
|
'https://cdn.roasmax.cn/upload/60c10adad4364f319c1139bfed719cda.mp4',
|
|
'https://cdn.roasmax.cn/upload/75adb7c620274200bf30950ee4af9a95.mp4',
|
|
'https://cdn.roasmax.cn/upload/c6d501f816a24354ab652d2b9083a32b.mp4',
|
|
]
|
|
|
|
const bgGif = require('@/assets/images/bg1.gif')
|
|
|
|
// ============ 工具函数 ============
|
|
function isVideoUrl(url: string) {
|
|
return url?.endsWith('.mp4')
|
|
}
|
|
|
|
// ============ 小组件 ============
|
|
const SpinningLoader = memo(() => {
|
|
const rotation = useSharedValue(0)
|
|
|
|
useEffect(() => {
|
|
rotation.value = withRepeat(withTiming(360, { duration: 1000, easing: Easing.linear }), -1, false)
|
|
}, [])
|
|
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ rotate: `${rotation.value}deg` }],
|
|
}))
|
|
|
|
return (
|
|
<Animated.View style={animatedStyle}>
|
|
<FontAwesome name="circle-o-notch" size={20} color="#FFE500" />
|
|
</Animated.View>
|
|
)
|
|
})
|
|
|
|
const DeviceItem = memo(({ device, onConnectToggle }: { device: any; onConnectToggle: (device: any) => void }) => {
|
|
const { name = 'Unknown Device', id, connected: isConnected } = device
|
|
|
|
return (
|
|
<Block className="relative mb-[16px] flex-row items-center overflow-hidden border-[3px] border-black bg-white p-[16px] shadow-large-black">
|
|
{isConnected && <Block className="absolute bottom-[0px] left-[0px] top-[0px] w-[16px] border-r-[3px] border-black bg-accent" />}
|
|
|
|
<Block className="h-[48px] w-[48px] items-center justify-center border-[3px] border-black bg-gray-100 shadow-soft-black-10">
|
|
<Ionicons name="phone-portrait-outline" size={24} color="black" />
|
|
</Block>
|
|
|
|
<Block className="relative z-[10] ml-[16px] flex-1 flex-row items-center justify-between gap-[16px]">
|
|
<Block className="flex-1">
|
|
<Block className="flex-1 flex-row gap-[8px]">
|
|
<Text className="break-words text-[18px] font-[900] italic">{name}</Text>
|
|
</Block>
|
|
<Block className="mt-[4px] flex-row gap-[8px]">
|
|
<Text
|
|
className={`border-[2px] border-black px-[6px] text-[10px] font-[900] italic ${isConnected ? 'bg-black text-accent' : 'bg-gray-200 text-gray-500'}`}
|
|
>
|
|
{isConnected ? '已连接' : '未连接'}
|
|
</Text>
|
|
</Block>
|
|
</Block>
|
|
|
|
<Block className="relative z-[10] items-center">
|
|
<Block
|
|
onClick={() => onConnectToggle(device)}
|
|
className={`h-[40px] items-center justify-center border-[3px] border-black px-[16px] text-[12px] font-[900] italic ${isConnected ? 'bg-accent text-black shadow-medium-black' : 'bg-white text-black shadow-medium-gray'}`}
|
|
>
|
|
<Text className="text-[12px] font-[900] italic">{isConnected ? '已连接' : '连接'}</Text>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
const GridItem = memo(
|
|
({
|
|
post,
|
|
isSelected,
|
|
isSelectionMode,
|
|
itemWidth,
|
|
isVisible,
|
|
onSelect,
|
|
}: {
|
|
post: any
|
|
isSelected: boolean
|
|
isSelectionMode: boolean
|
|
itemWidth: number
|
|
isVisible: boolean
|
|
onSelect: (post: any) => void
|
|
}) => {
|
|
return (
|
|
<Block onClick={() => onSelect(post)} 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={{ height: itemWidth, width: itemWidth }}>
|
|
{/* {isVideoUrl(post.imageUrl) ? (
|
|
<VideoBox url={post.imageUrl} style={{ height: itemWidth, width: itemWidth }} />
|
|
) : (
|
|
<Img src={post.imageUrl} className="h-full w-full" />
|
|
)} */}
|
|
<Img src={post.imageUrl} className="h-full w-full" />
|
|
</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>
|
|
)}
|
|
</Block>
|
|
</Block>
|
|
)
|
|
},
|
|
)
|
|
|
|
const FilterButtons = memo(({ isSelectionMode, onToggleSelectionMode }: { isSelectionMode: boolean; onToggleSelectionMode: () => void }) => {
|
|
return (
|
|
<Block className="flex flex-row items-center justify-between" style={{ marginVertical: 16 }}>
|
|
<Block className="flex-row">
|
|
{['我的生成'].map((label) => {
|
|
return (
|
|
<Block key={label} className={`-skew-x-[3deg] border-[2px] border-black bg-black px-[16px] py-[4px] shadow-small-accent`}>
|
|
<Text className={`text-[10px] font-[900] italic text-accent`}>{label}</Text>
|
|
</Block>
|
|
)
|
|
})}
|
|
</Block>
|
|
<Block
|
|
onClick={onToggleSelectionMode}
|
|
className={`border-[2px] border-black px-[16px] py-[6px] shadow-medium-black ${isSelectionMode ? 'bg-[#e61e25] text-white' : 'bg-accent text-black'}`}
|
|
>
|
|
<Text className="text-[12px] font-[900] italic">{isSelectionMode ? '取消' : '管理'}</Text>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
const SelectionBar = memo(
|
|
({
|
|
isSelectionMode,
|
|
selectedCount,
|
|
onSelectAll,
|
|
onDelete,
|
|
}: {
|
|
isSelectionMode: boolean
|
|
selectedCount: number
|
|
onSelectAll: () => void
|
|
onDelete: () => void
|
|
}) => {
|
|
const insets = useSafeAreaInsets()
|
|
if (!isSelectionMode) return null
|
|
|
|
return (
|
|
<Block className="absolute bottom-[96px] left-[16px] right-[16px] z-[50]" style={{ paddingBottom: insets.bottom }}>
|
|
<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="text-[14px] font-[900] italic">已选: {selectedCount}</Text>
|
|
<Block onClick={onSelectAll} className="text-[12px] underline">
|
|
<Text className="text-[12px] font-[700]">全选</Text>
|
|
</Block>
|
|
</Block>
|
|
<Block
|
|
onClick={onDelete}
|
|
className={`skew-x-[3deg] transform flex-row items-center space-x-[8px] border-[2px] border-black px-[16px] py-[8px] font-[[90]0] text-[14px] shadow-medium-black ${selectedCount > 0 ? 'bg-[#e61e25] text-white' : 'bg-gray-200 text-gray-400'}`}
|
|
>
|
|
<Ionicons name="trash-outline" size={16} color="black" />
|
|
<Text className="text-[14px] font-[900]">{selectedCount > 0 ? '删除' : '删除'}</Text>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
},
|
|
)
|
|
|
|
const FABButtons = memo(({ onGenAgain, onSync, canSync }: { onGenAgain: () => void; onSync: () => void; canSync: boolean }) => {
|
|
const insets = useSafeAreaInsets()
|
|
return (
|
|
<Block className="absolute bottom-[96px] left-[16px] right-[16px] z-[40]" style={{ paddingBottom: insets.bottom }}>
|
|
<Block className="w-full flex-row gap-[12px]">
|
|
<Block className="flex-1 -skew-x-6 shadow-large-black">
|
|
<Block
|
|
onClick={onGenAgain}
|
|
className={cn`relative h-[56px] items-center justify-center overflow-hidden border-[2px] border-black bg-[#e61e25] shadow-large-accent`}
|
|
>
|
|
<Block className="z-[10] skew-x-6 flex-row items-center gap-[8px]">
|
|
<MaterialCommunityIcons name="star-four-points" size={20} color="white" />
|
|
<Text className="font-[bol]d text-[16px] italic text-white">再次生成</Text>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
|
|
<Block className="flex-1 -skew-x-6 shadow-large-accent">
|
|
<Block onClick={onSync} className={cn`relative h-[56px] items-center justify-center overflow-hidden border-[2px] border-black bg-black`}>
|
|
<Block className="z-[10] flex-row items-center gap-[8px]">
|
|
<Ionicons name="refresh" size={20} color={'white'} />
|
|
<Text className={cn`font-[bol]d bg-black text-[16px] italic text-white`}>同步</Text>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
// ============ 主要组件部分 ============
|
|
const HeaderBanner = memo(({ connectedDevice, onPick }: { connectedDevice: any; onPick: () => void }) => {
|
|
return (
|
|
<Block className="relative z-[40] flex-row items-center justify-between py-[12px]">
|
|
<Block className="flex-row items-center gap-[8px] border-[2px] border-white bg-black py-[4px] shadow-hard-accent">
|
|
<Block className={`h-[8px] w-[8px] ${connectedDevice ? 'bg-accent' : 'bg-red-500'}`} />
|
|
<Text className="text-[12px] font-[900] tracking-[1px] text-white">{connectedDevice ? '设备已连接' : '设备离线'}</Text>
|
|
</Block>
|
|
<Block
|
|
onClick={onPick}
|
|
className="h-[40px] flex-row items-center justify-center gap-[8px] border-[3px] border-black bg-white px-[12px] shadow-hard-black"
|
|
>
|
|
<Ionicons name="cloud-upload-outline" size={18} color="black" />
|
|
<Text className="text-[12px] font-[900] italic text-black">上传本地</Text>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
const TopCircleSection = memo(
|
|
({
|
|
connectedDevice,
|
|
onStartConnect,
|
|
outerRingStyle,
|
|
selectedItem,
|
|
}: {
|
|
connectedDevice: any
|
|
onStartConnect: () => void
|
|
outerRingStyle: any
|
|
selectedItem: any
|
|
}) => {
|
|
return (
|
|
<Block className="relative z-[10] items-center">
|
|
<Block className="relative my-[24px] flex w-full flex-row items-center justify-between">
|
|
<Block
|
|
onClick={onStartConnect}
|
|
className={`w-[48px] items-center justify-center gap-[4px] border-[3px] border-black py-[12px] shadow-hard-black-50 ${connectedDevice ? 'bg-accent' : 'bg-white'}`}
|
|
>
|
|
<Ionicons name={connectedDevice ? 'wifi' : 'wifi-outline'} size={20} color={connectedDevice ? 'black' : '#6B7280'} />
|
|
<Text className="text-[10px] font-[900] italic">{connectedDevice ? '已连接' : '选择设备'}</Text>
|
|
</Block>
|
|
<Block className="relative h-[256px] w-[256px] items-center justify-center">
|
|
<Block
|
|
animated
|
|
style={outerRingStyle}
|
|
className="absolute inset-[-12px] rounded-full border-[3px] border-dashed border-black opacity-30"
|
|
/>
|
|
<Block>
|
|
<GalleryRenderer selectedItem={selectedItem} />
|
|
{connectedDevice && (
|
|
<Block className="absolute inset-[0px] items-center justify-center">
|
|
{/* <Ionicons name="desktop-outline" size={48} color="white" /> */}
|
|
<Text className="border border-white bg-black px-[12px] py-[2px] text-[12px] font-[900] italic text-white">离线模式</Text>
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
</Block>
|
|
<Block className="h-[10px] w-[48px]" />
|
|
</Block>
|
|
</Block>
|
|
)
|
|
},
|
|
)
|
|
|
|
const GalleryRenderer = memo(({ selectedItem }: { selectedItem: any }) => {
|
|
const uri = selectedItem?.url || selectedItem.imageUrl
|
|
console.log('GalleryRenderer------------', uri)
|
|
|
|
if (!uri) return null
|
|
|
|
const Width = 256
|
|
if (isVideoUrl(uri)) {
|
|
return (
|
|
<View style={{ width: Width, height: Width, borderRadius: Width, overflow: 'hidden' }} className="relative z-[10] border-[4px] border-black">
|
|
<VideoBox url={uri} style={{ width: Width, height: Width }} />
|
|
</View>
|
|
)
|
|
}
|
|
if (selectedItem?.type?.includes('video')) {
|
|
return <VideoBox url={uri} style={{ height: 256, width: 256 }} />
|
|
}
|
|
return <Img src={uri} className="h-full w-full" />
|
|
})
|
|
|
|
const ManagerView = memo(
|
|
({ viewState, onBack, onConnectToggle }: { viewState: string; onBack: () => void; onConnectToggle: (device: any) => void }) => {
|
|
const { discoveredDevices } = useBleExplorer()
|
|
|
|
if (viewState !== 'manager') return null
|
|
|
|
return (
|
|
<Block className="absolute inset-0" style={{ zIndex: 100 }}>
|
|
<Block className="relative flex-1 bg-paper py-[12px]">
|
|
<Block className="pt-safe flex-row items-center justify-between px-[16px] py-[16px]">
|
|
<Block onClick={onBack} className="h-[40px] w-[40px] items-center justify-center border-[3px] border-black bg-white shadow-medium-black">
|
|
<Ionicons name="chevron-back" size={24} color="black" />
|
|
</Block>
|
|
<Text className="text-[20px] font-[900] italic tracking-[-0.5px]">设备管理</Text>
|
|
<Block className="h-[40px] w-[40px] items-center justify-center bg-black/0">{/* 空占位符 */}</Block>
|
|
</Block>
|
|
|
|
<ScrollView className="relative flex-1 bg-paper">
|
|
<Block className="px-[16px] py-[16px]">
|
|
{discoveredDevices
|
|
.filter((i) => i.name)
|
|
.map((device) => (
|
|
<DeviceItem key={device.id} device={device} onConnectToggle={onConnectToggle} />
|
|
))}
|
|
</Block>
|
|
<Block className="h-[200px] w-full" />
|
|
</ScrollView>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
},
|
|
)
|
|
|
|
const BackgroundBanner = memo(({ selectedItem }: { selectedItem: any }) => {
|
|
const uri = selectedItem?.url || selectedItem.imageUrl
|
|
// if (!uri) return null
|
|
console.log('BackgroundBanner----', uri)
|
|
|
|
// if (isVideoUrl(uri)) {
|
|
// return (
|
|
// <Block className="absolute inset-0 bottom-0 left-0 right-0 top-0 z-[0] bg-red-400">
|
|
// <VideoBox
|
|
// url={'https://cdn.roasmax.cn/upload/c6d501f816a24354ab652d2b9083a32b.mp4'}
|
|
// viewType={1}
|
|
// style={{ width: screenWidth, height: screenHeight }}
|
|
// />
|
|
// <Block className="absolute inset-0 bg-black/0" />
|
|
// </Block>
|
|
// )
|
|
// }
|
|
return (
|
|
<Block className="absolute inset-0 bottom-0 left-0 right-0 top-0 z-[10] overflow-hidden">
|
|
{/* <VideoBox url={BACKGROUND_VIDEOS[0]} style={{ width: screenWidth, height: screenHeight }} /> */}
|
|
<Img src={bgGif} style={{ width: screenWidth, height: screenHeight }} />
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
// ============ 主组件 ============
|
|
const Sync = () => {
|
|
const { user } = useAuth()
|
|
const { data: generationsData, loading: generationsLoading, load: loadGenerations } = useTemplateGenerations()
|
|
const { uploadFile, loading: uploadLoading } = useFileUpload()
|
|
const { runTemplate, batchDeleteGenerations, loading: actionLoading } = useTemplateActions()
|
|
|
|
const [viewState, setViewState] = useState<'home' | 'manager' | 'scanner'>('home')
|
|
const [selectedItem, setSelectedItem] = useState({} as any)
|
|
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
|
|
const {
|
|
connectedDevice,
|
|
|
|
startScan,
|
|
stopScan,
|
|
connectToDevice,
|
|
disconnectDevice,
|
|
|
|
transferMediaSingle,
|
|
} = useBleExplorer()
|
|
|
|
const outerRotate = useSharedValue(0)
|
|
const itemWidth = Math.floor((screenWidth - 12 * 2 - 12 * 2) / 3)
|
|
|
|
// 加载生成记录
|
|
useEffect(() => {
|
|
if (user?.id) {
|
|
loadGenerations({ userId: user.id })
|
|
}
|
|
}, [user?.id, loadGenerations])
|
|
|
|
// 将生成记录转换为 posts 格式
|
|
const posts = useMemo(() => {
|
|
const generations = generationsData?.data || []
|
|
return generations.map((gen: any) => ({
|
|
id: gen.id,
|
|
imageUrl: Array.isArray(gen.resultUrl) ? gen.resultUrl[0] : gen.resultUrl,
|
|
originalUrl: gen.originalUrl,
|
|
templateId: gen.templateId,
|
|
type: gen.type,
|
|
status: gen.status,
|
|
createdAt: gen.createdAt,
|
|
title: `生成-${gen.id.slice(0, 6)}`,
|
|
rank: 'S',
|
|
author: user?.name || 'User',
|
|
avatarUrl: user?.image || 'https://image.pollinations.ai/prompt/cool%20anime%20boy%20avatar%20hoodie?seed=123&nologo=true',
|
|
}))
|
|
}, [generationsData, user])
|
|
|
|
const viewableIds = useRef<Set<string>>(new Set(posts.map((p: any) => p.id)))
|
|
|
|
// 动画效果
|
|
useEffect(() => {
|
|
outerRotate.value = withRepeat(withTiming(360, { duration: 12000, easing: Easing.linear }), -1, false)
|
|
}, [])
|
|
|
|
const outerRingStyle = useAnimatedStyle(() => ({
|
|
transform: [{ rotate: `${outerRotate.value}deg` }],
|
|
}))
|
|
|
|
// 事件处理函数
|
|
const handleConnectToggle = useCallback(
|
|
async (item: any) => {
|
|
if (item.connected) {
|
|
disconnectDevice()
|
|
} else {
|
|
await disconnectDevice()
|
|
Toast.showLoading({
|
|
title: '连接中...',
|
|
duration: 30e3,
|
|
})
|
|
connectToDevice(item)
|
|
.then(() => {
|
|
console.log('设备连接成功')
|
|
})
|
|
.catch(() => {
|
|
Toast.show({
|
|
title: '设备连接失败',
|
|
})
|
|
})
|
|
.finally(() => {
|
|
Toast.hideLoading()
|
|
})
|
|
}
|
|
},
|
|
[connectToDevice, disconnectDevice],
|
|
)
|
|
|
|
const canSync = useMemo(() => {
|
|
return !!connectedDevice?.id && !!selectedItem?.imageUrl
|
|
}, [connectedDevice, selectedItem])
|
|
|
|
const handleSync = useCallback(async () => {
|
|
// await aniStorage.set('test_url', { test: 'data' })
|
|
console.log(
|
|
'aniStorage.has----------------',
|
|
await aniStorage.has('test_url'),
|
|
// await aniStorage.delete('test_url'),
|
|
// await aniStorage.get('test_url'),
|
|
)
|
|
|
|
if (!canSync) {
|
|
Toast.show({ title: '请先连接设备' })
|
|
return
|
|
}
|
|
|
|
Toast.show({
|
|
renderContent: () => (
|
|
<Block
|
|
className="flex w-full flex-row items-center justify-center border-[3px] border-white bg-black"
|
|
style={{ paddingVertical: 14, transform: [{ skewX: '-6deg' }] }}
|
|
>
|
|
<SpinningLoader />
|
|
<Block style={{ marginLeft: 8 }}>
|
|
<Text className="font-[bla]ck text-[14px] italic text-white">正在同步文件...</Text>
|
|
</Block>
|
|
</Block>
|
|
),
|
|
duration: 0,
|
|
})
|
|
|
|
transferMediaSingle(selectedItem?.imageUrl)
|
|
.then(() => {
|
|
Toast.show({ title: '同步成功' })
|
|
})
|
|
.catch(() => {
|
|
Toast.hideLoading()
|
|
Toast.show({ title: '同步失败' })
|
|
})
|
|
}, [canSync, selectedItem, transferMediaSingle])
|
|
|
|
const handlePick = useCallback(async () => {
|
|
const assetList = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' })
|
|
if (!assetList?.length) return
|
|
|
|
const asset = assetList[0]
|
|
const isVideo = typeof asset === 'object' && asset?.type === 'video'
|
|
const uri = typeof asset === 'object' ? asset?.uri : asset
|
|
|
|
Toast.showLoading({ title: '上传中...', duration: 30e3 })
|
|
|
|
// 上传到云端
|
|
const fileBlob = await fetch(uri).then((r) => r.blob())
|
|
const mimeType = fileBlob.type || (isVideo ? 'video/mp4' : 'image/jpeg')
|
|
|
|
const file = new File([fileBlob], fileName, { type: mimeType })
|
|
|
|
const { url, error } = await uploadFile(file)
|
|
|
|
Toast.hideLoading()
|
|
|
|
if (error || !url) {
|
|
Toast.show({ title: '上传失败' })
|
|
return
|
|
}
|
|
|
|
const newItem = {
|
|
id: `local-${Date.now()}`,
|
|
type: isVideo ? 'video' : 'image',
|
|
imageUrl: url,
|
|
url,
|
|
originalUrl: url,
|
|
...(typeof asset === 'object' ? asset : {}),
|
|
}
|
|
|
|
setSelectedItem(newItem)
|
|
|
|
// // 如果设备已连接,询问是否同步
|
|
// 先预览不直接同步上传
|
|
// if (connectedDevice?.id) {
|
|
// Toast.showModal(
|
|
// <ConfirmModal
|
|
// content={<Text className="text-[14px] leading-relaxed text-gray-800">文件已上传,是否立即同步到设备?</Text>}
|
|
// onConfirm={() => {
|
|
// Toast.hideModal()
|
|
// handleSync()
|
|
// }}
|
|
// onCancel={() => Toast.hideModal()}
|
|
// />,
|
|
// )
|
|
// } else {
|
|
// Toast.show({ title: '上传成功' })
|
|
// }
|
|
}, [uploadFile, connectedDevice, handleSync])
|
|
|
|
const startConnect = useCallback(() => {
|
|
setViewState('manager')
|
|
startScan()
|
|
}, [startScan])
|
|
|
|
const handleGenAgain = useCallback(() => {
|
|
if (!selectedItem?.templateId) {
|
|
Toast.show({ title: '请先选择一个生成记录' })
|
|
return
|
|
}
|
|
|
|
Toast.showModal(
|
|
<ConfirmModal
|
|
content={
|
|
<Text className="font-[bol]d text-[14px] leading-relaxed text-gray-800">
|
|
生成同款风格将消耗 <Text className="font-[bla]ck mx-[4px] text-[20px] italic text-[#e61e25]">2 Goo</Text> 算力。
|
|
</Text>
|
|
}
|
|
onConfirm={handleGenAgainConfirm}
|
|
onCancel={() => Toast.hideModal()}
|
|
/>,
|
|
)
|
|
}, [selectedItem])
|
|
|
|
const handleGenAgainConfirm = useCallback(async () => {
|
|
if (!selectedItem?.templateId || !selectedItem?.originalUrl) return
|
|
|
|
Toast.hideModal()
|
|
Toast.show({
|
|
renderContent: () => (
|
|
<Block
|
|
className="flex w-full flex-row items-center justify-center border-[3px] border-white bg-black"
|
|
style={{ paddingVertical: 14, transform: [{ skewX: '-6deg' }] }}
|
|
>
|
|
<SpinningLoader />
|
|
<Block style={{ marginLeft: 8 }}>
|
|
<Text className="font-[bla]ck text-[14px] italic text-white">正在生成中...</Text>
|
|
</Block>
|
|
</Block>
|
|
),
|
|
duration: 0,
|
|
})
|
|
|
|
const { generationId, error } = await runTemplate({
|
|
templateId: selectedItem.templateId,
|
|
data: {},
|
|
originalUrl: selectedItem.originalUrl,
|
|
})
|
|
|
|
Toast.hideLoading()
|
|
|
|
if (error || !generationId) {
|
|
Toast.show({ title: error?.message || '生成失败' })
|
|
return
|
|
}
|
|
|
|
Toast.show({
|
|
renderContent: () => (
|
|
<Block
|
|
className="flex w-full flex-row items-center justify-center border-[3px] border-white bg-black"
|
|
style={{ paddingVertical: 14, transform: [{ skewX: '-6deg' }] }}
|
|
>
|
|
<AntDesign name="check-circle" size={20} color="white" />
|
|
<Block style={{ marginLeft: 8 }}>
|
|
<Text className="font-[bla]ck text-[14px] italic text-white">生成成功</Text>
|
|
</Block>
|
|
</Block>
|
|
),
|
|
})
|
|
|
|
// 刷新列表
|
|
if (user?.id) {
|
|
loadGenerations({ userId: user.id })
|
|
}
|
|
}, [selectedItem, runTemplate, user?.id, loadGenerations])
|
|
|
|
const toggleSelectionMode = useCallback(() => {
|
|
setIsSelectionMode((v) => !v)
|
|
setSelectedIds(new Set())
|
|
}, [])
|
|
|
|
const handleItemSelect = useCallback(
|
|
(post: any) => {
|
|
if (isSelectionMode) {
|
|
const next = new Set(selectedIds)
|
|
if (next.has(post.id)) next.delete(post.id)
|
|
else next.add(post.id)
|
|
setSelectedIds(next)
|
|
} else {
|
|
setSelectedItem(post)
|
|
}
|
|
},
|
|
[isSelectionMode, selectedIds],
|
|
)
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (selectedIds.size === 0) return
|
|
|
|
const { success, error } = await batchDeleteGenerations(Array.from(selectedIds))
|
|
|
|
if (error || !success) {
|
|
Toast.show({ title: error?.message || '删除失败' })
|
|
return
|
|
}
|
|
|
|
Toast.show({ title: `成功删除 ${selectedIds.size} 个生成记录` })
|
|
|
|
// 刷新列表
|
|
if (user?.id) {
|
|
await loadGenerations({ userId: user.id })
|
|
}
|
|
|
|
setSelectedIds(new Set())
|
|
setIsSelectionMode(false)
|
|
}, [selectedIds, batchDeleteGenerations, user?.id, loadGenerations])
|
|
|
|
const selectAll = useCallback(() => {
|
|
if (selectedIds.size === posts.length) {
|
|
setSelectedIds(new Set())
|
|
} else {
|
|
setSelectedIds(new Set(posts.map((p: any) => p.id)))
|
|
}
|
|
}, [posts, selectedIds.size])
|
|
|
|
const onViewableItemsChanged = useCallback((params: any) => {
|
|
const { viewableItems } = params
|
|
viewableIds.current = new Set(viewableItems.map((v: any) => v.item.id))
|
|
}, [])
|
|
|
|
const renderHeaderFlatList = useMemo(
|
|
() => (
|
|
<Block className="z-10">
|
|
<HeaderBanner connectedDevice={connectedDevice} onPick={handlePick} />
|
|
<TopCircleSection
|
|
selectedItem={selectedItem}
|
|
connectedDevice={connectedDevice}
|
|
onStartConnect={startConnect}
|
|
outerRingStyle={outerRingStyle}
|
|
/>
|
|
<FilterButtons isSelectionMode={isSelectionMode} onToggleSelectionMode={toggleSelectionMode} />
|
|
</Block>
|
|
),
|
|
[connectedDevice, handlePick, startConnect, outerRingStyle, isSelectionMode, toggleSelectionMode, selectedItem],
|
|
)
|
|
|
|
const renderGridItem = useCallback(
|
|
({ item: post }: { item: any }) => {
|
|
const isSelected = isSelectionMode ? selectedIds.has(post.id) : selectedItem?.id === post.id
|
|
const isVisible = viewableIds.current.has(post.id)
|
|
|
|
return (
|
|
<GridItem
|
|
post={post}
|
|
isSelected={isSelected}
|
|
isSelectionMode={isSelectionMode}
|
|
itemWidth={itemWidth}
|
|
isVisible={isVisible}
|
|
onSelect={handleItemSelect}
|
|
/>
|
|
)
|
|
},
|
|
[isSelectionMode, selectedIds, selectedItem, itemWidth, handleItemSelect],
|
|
)
|
|
|
|
return (
|
|
<Block className="relative flex-1">
|
|
<BackgroundBanner selectedItem={selectedItem} />
|
|
|
|
<Block className="z-10 flex-1">
|
|
<FlashList
|
|
data={posts}
|
|
numColumns={3}
|
|
getItemType={() => 'row'}
|
|
removeClippedSubviews
|
|
keyExtractor={(item: any) => item?.id}
|
|
ListHeaderComponent={renderHeaderFlatList}
|
|
renderItem={renderGridItem}
|
|
ItemSeparatorComponent={() => <Block style={{ height: 6 }} />}
|
|
contentContainerStyle={{ paddingHorizontal: 12, paddingBottom: 200 }}
|
|
viewabilityConfig={{ itemVisiblePercentThreshold: 60 }}
|
|
onViewableItemsChanged={onViewableItemsChanged}
|
|
/>
|
|
</Block>
|
|
|
|
<FABButtons onGenAgain={handleGenAgain} onSync={handleSync} canSync={canSync} />
|
|
|
|
<SelectionBar isSelectionMode={isSelectionMode} selectedCount={selectedIds.size} onSelectAll={selectAll} onDelete={handleDelete} />
|
|
|
|
<ManagerView
|
|
viewState={viewState}
|
|
onBack={() => {
|
|
setViewState('home')
|
|
stopScan()
|
|
}}
|
|
onConnectToggle={handleConnectToggle}
|
|
/>
|
|
</Block>
|
|
)
|
|
}
|
|
|
|
export default memo(Sync)
|