import { AntDesign, EvilIcons, Ionicons, MaterialCommunityIcons } from '@expo/vector-icons' import { useIsFocused } from '@react-navigation/native' import { Block, ConfirmModal, Img, Input, ListEmpty, SpinningLoader, SyncProgressToast, Text, Toast, VideoBox, } from '@share/components' import { FlashList } from '@shopify/flash-list' import * as ImagePicker from 'expo-image-picker' import { router } from 'expo-router' import { observer } from 'mobx-react-lite' import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, RefreshControl, ScrollView } from 'react-native' import { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { imgPicker } from '@/@share/apis' import { bleManager } from '@/ble/managers/bleManager' import BannerSection from '@/components/BannerSection' import { useTemplateActions } from '@/hooks/actions/use-template-actions' import { useTemplateGenerations } from '@/hooks/data/use-template-generations' import { bleStore, userStore } from '@/stores' import { screenWidth, uploadFile } from '@/utils' import { cn } from '@/utils/cn' import { extractCdnKey } from '@/utils/getCDNKey' const ITEM_GAP = 6 // FlashList 会将可用宽度(screenWidth - padding*2)平均分配给每列 // 所以每个item容器的宽度是 (screenWidth - 24) / 3 // item实际显示宽度需要减去间距 const ITEM_CONTAINER_WIDTH = Math.floor((screenWidth - 12 * 2) / 3) const ITEM_WIDTH = ITEM_CONTAINER_WIDTH - ITEM_GAP // ============ 主组件 ============ const Sync = observer(() => { // 从MobX Store获取用户信息 const { user, isLogin } = userStore const insets = useSafeAreaInsets() const { data: generationsData, loading: generationsLoading, loadingMore, load: loadGenerations, loadMore, refetch, } = useTemplateGenerations() const { reRunTemplate, batchDeleteGenerations, loading: actionLoading } = useTemplateActions() const [viewState, setViewState] = useState<'home' | 'manager' | 'scanner'>('home') const [selectedItem, setSelectedItem] = useState({ id: '', imageUrl: '', // url 是网络地址,本地预览使用 imageUrl webpPreviewUrl: '', webpHighPreviewUrl: '', url: '', originalUrl: '', templateId: '', asset: {}, price: 0, }) const [isSelectionMode, setIsSelectionMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) // 直接使用 useIsFocused hook,页面失焦时不渲染列表项以减少内存占用 const isFocused = useIsFocused() const { connectedDevice, isScanning, transferProgress } = bleStore.state // 加载生成记录 useEffect(() => { if (isFocused) { // loadGenerations() } }, [isFocused]) // 将生成记录转换为 posts 格式 const posts = useMemo(() => { const generations = generationsData || [] return generations?.map((gen: any) => { const imageUrl = Array.isArray(gen?.resultUrl) ? gen?.resultUrl[0] : gen?.resultUrl const coverUrl = gen?.template?.coverImageUrl return { id: gen?.id, itemType: 'post' as const, // 模板静态图片 coverUrl: coverUrl, imageUrl: imageUrl, url: imageUrl, webpPreviewUrl: gen.webpPreviewUrl, webpHighPreviewUrl: gen.webpHighPreviewUrl, originalUrl: gen?.originalUrl, templateId: gen?.templateId, type: gen?.type, status: gen?.status, createdAt: gen?.createdAt, price: gen?.template?.price || -1, 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]) useEffect(() => { if (!selectedItem?.id && posts.length > 0) { const firstItem = posts.filter((p: any) => p.status === 'completed' || p.status === 'success')[0] if (firstItem?.id) { const newItem = { id: firstItem.id, imageUrl: firstItem.imageUrl, url: firstItem.url, webpPreviewUrl: firstItem.webpPreviewUrl, webpHighPreviewUrl: firstItem.webpHighPreviewUrl, originalUrl: firstItem.originalUrl, templateId: firstItem.templateId, asset: firstItem?.asset || {}, price: firstItem?.price || -1, } setSelectedItem(newItem) } } }, [posts]) // console.log('selectedItem-----------', selectedItem) const canSync = useMemo(() => { return !!connectedDevice?.id }, [connectedDevice, selectedItem]) const handleSync = async () => { if (!canSync) { Toast.show({ title: '请先连接设备' }) return } if (!selectedItem?.imageUrl) { Toast.show({ title: '请等待模板生成完成再同步' }) return } const transferring = bleStore.state.loading.transferring || bleStore.state.loading.converting if (transferring) { Toast.show({ title: '已有文件同步中,请稍后' }) return } // 先预览直接转 ani 同步 let fileUrl = selectedItem?.url // 是本地文件则先上传 if (!selectedItem?.url && selectedItem?.asset?.uri) { Toast.showLoading({ title: '上传中...', duration: 30e3 }) const result = selectedItem?.asset const url = await uploadFile({ uri: result.uri, mimeType: result.mimeType, fileName: result.fileName, }) fileUrl = url setSelectedItem((prev) => ({ ...prev, url: fileUrl })) Toast.hideLoading() } console.log('handlePick------------', fileUrl) bleStore.setState((prestate) => { return { ...prestate, transferProgress: 0 } }) Toast.showLoading({ renderContent: () => , duration: 0, // 同步时禁用返回按钮 onBackButtonPress: () => {}, }) bleManager .transferMediaSingle(fileUrl) .then(() => { const key = extractCdnKey(fileUrl) bleStore.addGalleryItem(key!) Toast.show({ title: '同步成功' }) }) .catch((e) => { console.log('transferMediaSingle e--------', e) Toast.show({ title: e || '同步失败' }) }) .finally(() => { Toast.hideLoading() }) } const handlePick = useCallback(async () => { const assetList = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' }) if (!assetList?.length) return const result = assetList[0] as any console.log('result----------', result) const newItem = { id: `local-${Date.now()}`, // type: isVideo ? 'video' : 'image', imageUrl: result?.uri, url: '', webpPreviewUrl: '', webpHighPreviewUrl: '', coverUrl: '', originalUrl: result?.uri, templateId: '', asset: result, price: -1, } setSelectedItem(newItem) }, [connectedDevice, handleSync]) const startConnect = useCallback(() => { setViewState('manager') bleManager.startScan() }, []) // 当离开设备管理页面时停止扫描 useEffect(() => { if (viewState !== 'manager' && isScanning) { bleManager.stopScan() } }, [viewState, isScanning]) const handleGenAgain = useCallback(() => { console.log('selectedItem==========', selectedItem) if (!selectedItem?.templateId) { Toast.show({ title: '请先选择一个生成记录' }) return } const price = selectedItem?.price if (price <= 0) { Toast.show({ title: '请先上传' }) return } Toast.showModal( 生成同款风格将消耗 {price} 算力。 } onCancel={() => Toast.hideModal()} onConfirm={handleGenAgainConfirm} />, ) }, [selectedItem]) const handleGenAgainConfirm = async () => { console.log('handleGenAgainConfirm selectedItem-----------', selectedItem) if (!selectedItem?.templateId) { Toast.show({ title: '请先选择一个生成记录' }) return } Toast.hideModal() const { generationId, error } = await reRunTemplate({ generationId: selectedItem?.id, }) Toast.hideLoading() if (error || !generationId) { Toast.show({ title: error?.message || '生成失败' }) return } Toast.show({ renderContent: () => ( 生成中... ), }) // 刷新列表 if (user?.id) { loadGenerations() } } const toggleSelectionMode = useCallback(() => { setIsSelectionMode((v) => !v) setSelectedIds(new Set()) }, []) const handleItemSelect = (post: any) => { if (!post?.id) return // 防止访问 undefined 的 id 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) } } 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() } setSelectedIds(new Set()) setIsSelectionMode(false) }, [selectedIds, batchDeleteGenerations, user?.id, loadGenerations]) const selectAll = useCallback(() => { const validPosts = posts.filter((p: any) => p?.id) // 过滤掉没有 id 的 posts if (selectedIds.size === validPosts.length) { setSelectedIds(new Set()) } else { setSelectedIds(new Set(validPosts.map((p: any) => p.id))) } }, [posts, selectedIds.size]) // 下拉刷新 const [refreshing, setRefreshing] = useState(false) const onRefresh = useCallback(async () => { setRefreshing(true) try { await refetch() } finally { setRefreshing(false) } }, [refetch]) // 加载更多 const onLoadMore = () => { // console.log('loadMore----------------') loadMore() } // 列表底部组件 const ListFooter = useMemo(() => { if (loadingMore) { return ( ) } return null }, [loadingMore]) // Header 组件 const renderHeader = useMemo( () => ( ), [connectedDevice, handlePick, selectedItem, startConnect, isSelectionMode, toggleSelectionMode], ) const renderItem = useCallback( ({ item }: { item: any }) => { const isSelected = isSelectionMode ? selectedIds.has(item?.id) : selectedItem?.id === item?.id return ( ) }, [isSelectionMode, selectedIds, selectedItem?.id, handleItemSelect], ) if (!isFocused) { return null } // 计算底部内边距:FAB按钮高度(56) + 按钮底部距离(96) + 安全区域 + 额外间距 const listPaddingBottom = 56 + 96 + insets.bottom + 20 return ( item?.id} ListHeaderComponent={renderHeader} ListFooterComponent={ListFooter} ListEmptyComponent={} numColumns={3} drawDistance={300} maxItemsInRecyclePool={0} // 自动移除不可见的原生视图,停止渲染和解码 removeClippedSubviews={true} renderItem={renderItem} refreshControl={ } onEndReached={onLoadMore} onEndReachedThreshold={0.3} /> { setViewState('home') bleManager.stopScan() }} /> ) }) export default Sync // ============ 小组件 ============ const UpdateNameModal = ({ initialName, onNameChange, onConfirm, onCancel }: any) => { const [name, setName] = useState(initialName) useEffect(() => { setName(initialName) }, [initialName]) const handleChange = (text: string) => { setName(text) onNameChange && onNameChange(text) } return ( } onConfirm={onConfirm} onCancel={onCancel} /> ) } const DeviceItem = observer(({ device }: { device: any }) => { const { name = 'Unknown Device', id, connected: isConnected } = device const bindDevice = bleStore.bindDeviceList?.find((d) => d?.id === id) const [nameShow, setNameShow] = useState(bindDevice ? `${bindDevice?.name}` : name) const tempName = useRef(nameShow) const canEdit = !!bindDevice && isConnected // 绑定过的设备 const hasBind = !!bleStore.bindDeviceList?.find((d) => d?.id === id) const { id: userId } = userStore.user || {} const onConnectToggle = async (device: any) => { if (device.connected) { bleManager.disconnectDevice() } else { await bleManager.disconnectDevice() Toast.showLoading({ title: '连接中...', duration: 30e3, }) bleManager .connectToDevice(device) .then(() => { console.log('设备连接成功') if (userId) { bleManager.bindDevice(userId).then((res) => { // console.log('Bind device response------------:', res) if (res.success !== 1) { Toast.show({ title: '设备已经被其他用户绑定过了' }) bleManager.disconnectDevice() } }) } }) .catch(() => { Toast.show({ title: '设备连接失败', }) }) .finally(() => { Toast.hideLoading() }) } } const handleUnbindDevice = async (device: any) => { if (!userId) { Toast.show({ title: '用户未登录' }) return } Toast.showModal( Toast.hideModal()} onConfirm={() => { Toast.hideModal() unbindDevice(device) }} />, ) } const unbindDevice = async (device: any) => { Toast.showLoading({ title: '解绑中...', duration: 30e3 }) try { await bleManager.unBindDevice(userId!) bleManager.disconnectDevice() bleStore.removeBindDeviceItem(device.id) setNameShow(device.name || 'Unknown Device') Toast.show({ title: '解绑成功' }) } catch (error: any) { Toast.show({ title: `解绑失败: ${error?.message || error}` }) } finally { Toast.hideLoading() } } const handleUpdateName = () => { if (!canEdit) return Toast.showModal( { if (text.length > 12) { Toast.show({ title: '设备名称不能超过12个字符' }) return } tempName.current = text }} onConfirm={() => { console.log('onconfirm-----------', tempName) if (bindDevice) { bleStore.updateBindDeviceItem({ ...bindDevice, name: tempName.current }) setNameShow(tempName.current) } Toast.hideModal() }} onCancel={() => { Toast.hideModal() tempName.current = nameShow }} />, ) } return ( {isConnected && } {nameShow} {canEdit && } {isConnected ? '已连接' : '未连接'} {hasBind && ( 已绑定 )} onConnectToggle(device)} > {isConnected ? '已连接' : '连接'} {canEdit && ( handleUnbindDevice(device)} > 解绑 )} ) }) const GridItem = memo( ({ post, isSelected, isSelectionMode, itemWidth, onSelect, }: { post: any isSelected: boolean isSelectionMode: boolean itemWidth: number onSelect: (post: any) => void }) => { // 渲染状态标记 const renderStatusBadge = () => { const status = post.status // 成功状态不显示标记 if (status === 'completed' || status === 'success') { return null } // 失败状态 if (status === 'failed') { return ( 失败 ) } // 其他状态显示加载中 return ( 生成中 ) } // const imgShow = post.webpPreviewUrl || post.coverUrl const imgShow = post.coverUrl // console.log('imgShow----------', imgShow) const placeholderSrc = null // 新数据使用webp 旧数据使用mp4 const canShow = post.status === 'completed' || post.status === 'success' // 容器高度 = item高度 + 底部间距 const containerHeight = itemWidth + 6 return ( onSelect(post)} > {canShow && } {isSelected && } {/* 状态标记 */} {renderStatusBadge()} {isSelectionMode && ( )} ) }, ) const FilterButtons = observer( ({ isSelectionMode, onToggleSelectionMode }: { isSelectionMode: boolean; onToggleSelectionMode: () => void }) => { const { isConnected } = bleStore.state return ( {['我的生成'].map((label) => { return ( {label} ) })} {isConnected && ( { router.push('/device') }} > {'吧唧管理'} )} {isSelectionMode ? '取消' : '管理'} ) }, ) const SelectionBar = memo( ({ isSelectionMode, selectedCount, onSelectAll, onDelete, }: { isSelectionMode: boolean selectedCount: number onSelectAll: () => void onDelete: () => void }) => { const insets = useSafeAreaInsets() if (!isSelectionMode) return null return ( 已选: {selectedCount} 全选 0 ? 'bg-[#e61e25] text-white' : 'bg-gray-200 text-gray-400'}`} onClick={onDelete} > {selectedCount > 0 ? '删除' : '删除'} ) }, ) const FABButtons = memo(({ onGenAgain, handleSync }: { onGenAgain: () => void; handleSync: () => void }) => { const insets = useSafeAreaInsets() return ( 再次生成 同步 ) }) // ============ 主要组件部分 ============ const HeaderBanner = observer(({ connectedDevice, onPick }: { connectedDevice: any; onPick: () => void }) => { const { user, isLogin, signOut } = userStore // console.log('user-----------', user) // console.log('session--------', session) const handleLogout = () => { if (isLogin) { router.push('/settings') } } const loginText = isLogin ? '设置' : '登录' return ( {connectedDevice ? '设备已连接' : '设备离线'} {loginText} 上传本地 ) }) const TopCircleSection = observer( ({ onStartConnect, selectedItem }: { onStartConnect: () => void; selectedItem: any }) => { const { connectedDevice } = bleStore.state const outerRotate = useSharedValue(0) useEffect(() => { outerRotate.value = withRepeat(withTiming(360, { duration: 12000, easing: Easing.linear }), -1, false) }, []) const outerRingStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${outerRotate.value}deg` }], })) return ( {connectedDevice ? '已连接' : '选择设备'} {/* {!connectedDevice?.id && ( 离线模式 )} */} ) }, ) const GalleryRenderer = memo(({ selectedItem }: { selectedItem: any }) => { // 本地和线上都优先使用imageUrl, 本地上传一开始没有 url const url = selectedItem?.webpHighPreviewUrl || selectedItem?.imageUrl || selectedItem?.url const placeholderUrl = selectedItem?.webpPreviewUrl // console.log('GalleryRenderer--------------', selectedItem) if (!url) return null const Width = 256 return ( ) }) const ManagerView = observer(({ viewState, onBack }: { viewState: string; onBack: () => void }) => { const { isScanning, discoveredDevices } = bleStore.state // console.log('isScanning------------', isScanning) // console.log('discoveredDevices------------', discoveredDevices) if (viewState !== 'manager') return null const deviceList = discoveredDevices.filter((i) => i.name) return ( 设备管理 {/* 空占位符 */} 正在扫描设备... {deviceList.map((device) => ( ))} ) })