1011 lines
33 KiB
TypeScript
1011 lines
33 KiB
TypeScript
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_WIDTH = Math.floor((screenWidth - 12 * 2 - 12 * 2) / 3)
|
||
|
||
// ============ 主组件 ============
|
||
const Sync = observer(() => {
|
||
// 从MobX Store获取用户信息
|
||
const { user, isLogin } = userStore
|
||
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<Set<string>>(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,
|
||
|
||
// 模板静态图片
|
||
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 && !!selectedItem?.imageUrl
|
||
}, [connectedDevice, selectedItem])
|
||
|
||
const handleSync = async () => {
|
||
if (!canSync) {
|
||
Toast.show({ title: '请先连接设备' })
|
||
return
|
||
}
|
||
|
||
const transferring = bleStore.state.loading.transferring
|
||
|
||
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: () => <SyncProgressToast />,
|
||
duration: 0,
|
||
})
|
||
|
||
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(
|
||
<ConfirmModal
|
||
title="确认生成同款风格吗?"
|
||
content={
|
||
<Text className="font-[bol]d text-[14px] leading-relaxed text-gray-800">
|
||
生成同款风格将消耗 <Text className="font-[bla]ck mx-[4px] text-[20px] text-[#e61e25]">{price} </Text> 算力。
|
||
</Text>
|
||
}
|
||
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: () => (
|
||
<Block
|
||
className="flex w-full flex-row items-center justify-center border-[3px] border-white bg-black"
|
||
style={{ paddingVertical: 14, transform: [{ skewX: '-6deg' }] }}
|
||
>
|
||
<AntDesign color="white" name="check-circle" size={20} />
|
||
<Block style={{ marginLeft: 8 }}>
|
||
<Text className="font-[bla]ck text-[14px] text-white">生成中...</Text>
|
||
</Block>
|
||
</Block>
|
||
),
|
||
})
|
||
|
||
// 刷新列表
|
||
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 (
|
||
<Block className="items-center justify-center py-[20px]">
|
||
<ActivityIndicator color="#FFE500" size="small" />
|
||
</Block>
|
||
)
|
||
}
|
||
return null
|
||
}, [loadingMore])
|
||
|
||
const renderHeader = useMemo(() => {
|
||
return (
|
||
<Block className="z-10">
|
||
<HeaderBanner connectedDevice={connectedDevice} onPick={handlePick} />
|
||
<TopCircleSection selectedItem={selectedItem} onStartConnect={startConnect} />
|
||
<FilterButtons isSelectionMode={isSelectionMode} onToggleSelectionMode={toggleSelectionMode} />
|
||
</Block>
|
||
)
|
||
}, [connectedDevice, handlePick, startConnect, isSelectionMode, toggleSelectionMode, selectedItem])
|
||
|
||
const renderGridItem = ({ item: post }: { item: any }) => {
|
||
const isSelected = isSelectionMode ? selectedIds.has(post?.id) : selectedItem?.id === post?.id
|
||
|
||
return (
|
||
<GridItem
|
||
isSelected={isSelected}
|
||
isSelectionMode={isSelectionMode}
|
||
itemWidth={ITEM_WIDTH}
|
||
post={post}
|
||
// 页面失焦时不渲染,减少内存占用
|
||
onSelect={handleItemSelect}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (!isFocused) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<Block className="relative flex-1 bg-black">
|
||
<BannerSection />
|
||
|
||
<Block className="z-10 flex-1">
|
||
<FlashList
|
||
contentContainerStyle={{ paddingHorizontal: 12, paddingBottom: 200 }}
|
||
data={posts}
|
||
// drawDistance={300}
|
||
maxItemsInRecyclePool={0}
|
||
removeClippedSubviews={true}
|
||
ItemSeparatorComponent={() => <Block style={{ height: 6 }} />}
|
||
keyExtractor={(item: any) => item?.id}
|
||
ListFooterComponent={ListFooter}
|
||
ListHeaderComponent={renderHeader}
|
||
ListEmptyComponent={<ListEmpty />}
|
||
numColumns={3}
|
||
renderItem={renderGridItem}
|
||
refreshControl={
|
||
<RefreshControl colors={['#FFE500']} refreshing={refreshing} tintColor="#FFE500" onRefresh={onRefresh} />
|
||
}
|
||
onEndReached={onLoadMore}
|
||
onEndReachedThreshold={0.3}
|
||
/>
|
||
</Block>
|
||
|
||
<FABButtons onGenAgain={handleGenAgain} handleSync={handleSync} />
|
||
|
||
<SelectionBar
|
||
isSelectionMode={isSelectionMode}
|
||
selectedCount={selectedIds.size}
|
||
onDelete={handleDelete}
|
||
onSelectAll={selectAll}
|
||
/>
|
||
|
||
<ManagerView
|
||
viewState={viewState}
|
||
onBack={() => {
|
||
setViewState('home')
|
||
bleManager.stopScan()
|
||
}}
|
||
/>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
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 (
|
||
<ConfirmModal
|
||
title="修改设备名称"
|
||
content={
|
||
<Block className="w-full gap-[12px] border">
|
||
<Input className="w-full" placeholder="请输入设备名称" value={name} onChangeText={handleChange} />
|
||
</Block>
|
||
}
|
||
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(
|
||
<ConfirmModal
|
||
title="解绑设备"
|
||
content={`是否解绑设备?`}
|
||
onCancel={() => 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(
|
||
<UpdateNameModal
|
||
initialName={tempName.current}
|
||
onNameChange={(text: string) => {
|
||
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 (
|
||
<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 inset-y-0 left-0 w-[16px] border-r-[3px] border-black bg-accent" />}
|
||
|
||
<Block className="size-[48px] items-center justify-center border-[3px] border-black bg-gray-100 shadow-soft-black-10">
|
||
<Ionicons color="black" name="phone-portrait-outline" size={24} />
|
||
</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 items-center gap-[8px]" onClick={handleUpdateName}>
|
||
<Block className="flex-row flex-wrap items-center">
|
||
<Text className="break-words text-[18px] font-[900]">{nameShow}</Text>
|
||
{canEdit && <EvilIcons name="pencil" size={24} color="black" />}
|
||
</Block>
|
||
</Block>
|
||
|
||
<Block className="relative z-10 mt-[4px] flex-row items-center">
|
||
<Block className="flex-row">
|
||
<Block className="">
|
||
<Text
|
||
className={`border-2 border-black px-[6px] text-[10px] font-[900] ${isConnected ? 'bg-black text-accent' : 'bg-gray-200 text-gray-500'}`}
|
||
>
|
||
{isConnected ? '已连接' : '未连接'}
|
||
</Text>
|
||
</Block>
|
||
|
||
{hasBind && (
|
||
<Block className="ml-[8px]">
|
||
<Text
|
||
className={`border-2 border-black px-[6px] text-[10px] font-[900] ${isConnected ? 'bg-black text-accent' : 'bg-gray-200 text-gray-500'}`}
|
||
>
|
||
已绑定
|
||
</Text>
|
||
</Block>
|
||
)}
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
|
||
<Block className="relative z-10 flex-col items-end space-x-[8px]">
|
||
<Block
|
||
className={`h-[40px] items-center justify-center border-[3px] border-black px-[16px] text-[12px] font-[900] ${isConnected ? 'bg-accent text-black shadow-medium-black' : 'bg-white text-black shadow-medium-gray'}`}
|
||
onClick={() => onConnectToggle(device)}
|
||
>
|
||
<Text className="text-[12px] font-[900]">{isConnected ? '已连接' : '连接'}</Text>
|
||
</Block>
|
||
{canEdit && (
|
||
<Block
|
||
className={`mt-[4px] h-[40px] items-center justify-center border-[3px] border-black bg-white px-[16px] text-[12px] font-[900] text-black shadow-medium-gray`}
|
||
onClick={() => handleUnbindDevice(device)}
|
||
>
|
||
<Text className={`text-[12px] font-[900]`}>解绑</Text>
|
||
</Block>
|
||
)}
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
const GridItem = memo(
|
||
({
|
||
post,
|
||
isSelected,
|
||
isSelectionMode,
|
||
itemWidth,
|
||
isVisible = true,
|
||
onSelect,
|
||
}: {
|
||
post: any
|
||
isSelected: boolean
|
||
isSelectionMode: boolean
|
||
itemWidth: number
|
||
isVisible?: boolean
|
||
onSelect: (post: any) => void
|
||
}) => {
|
||
// 渲染状态标记
|
||
const renderStatusBadge = () => {
|
||
const status = post.status
|
||
|
||
// 成功状态不显示标记
|
||
if (status === 'completed' || status === 'success') {
|
||
return null
|
||
}
|
||
|
||
// 失败状态
|
||
if (status === 'failed') {
|
||
return (
|
||
<Block
|
||
className="absolute left-[8px] top-[8px] z-20 flex-row items-center gap-[4px] border-2 border-black bg-[#e61e25] px-[8px] py-[4px] shadow-hard-black"
|
||
style={{ transform: [{ skewX: '6deg' }] }}
|
||
>
|
||
<Ionicons color="white" name="close-circle" size={14} />
|
||
<Text className="text-[10px] font-[900] text-white">失败</Text>
|
||
</Block>
|
||
)
|
||
}
|
||
|
||
// 其他状态显示加载中
|
||
return (
|
||
<Block
|
||
className="absolute left-[8px] top-[8px] z-20 flex-row items-center gap-[4px] border-2 border-black bg-white px-[8px] py-[4px] shadow-hard-black"
|
||
style={{ transform: [{ skewX: '6deg' }] }}
|
||
>
|
||
<SpinningLoader />
|
||
<Text className="text-[10px] font-[900] text-black">生成中</Text>
|
||
</Block>
|
||
)
|
||
}
|
||
|
||
const imgShow = post.webpPreviewUrl || post.imageUrl
|
||
// console.log('imgShow----------', imgShow)
|
||
const placeholderSrc = null
|
||
// 新数据使用webp 旧数据使用mp4
|
||
|
||
return (
|
||
<Block className="relative" key={post?.id} onClick={() => onSelect(post)}>
|
||
<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={{ height: itemWidth, width: itemWidth }}>
|
||
{isVisible && <Img className="size-full" src={imgShow} />}
|
||
</Block>
|
||
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
|
||
|
||
{/* 状态标记 */}
|
||
{renderStatusBadge()}
|
||
|
||
{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 color="black" name="checkmark" size={20} />
|
||
</Block>
|
||
</Block>
|
||
)}
|
||
</Block>
|
||
</Block>
|
||
)
|
||
},
|
||
)
|
||
|
||
const FilterButtons = observer(
|
||
({ isSelectionMode, onToggleSelectionMode }: { isSelectionMode: boolean; onToggleSelectionMode: () => void }) => {
|
||
const { isConnected } = bleStore.state
|
||
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-3 border-2 border-black bg-black px-[16px] py-[4px] shadow-small-accent`}
|
||
>
|
||
<Text className={`text-[10px] font-[900] text-accent`}>{label}</Text>
|
||
</Block>
|
||
)
|
||
})}
|
||
</Block>
|
||
|
||
<Block className="flex-row items-center gap-[12px]">
|
||
{isConnected && (
|
||
<Block
|
||
className={`border-2 border-black bg-accent px-[16px] py-[6px] text-white shadow-medium-black`}
|
||
onClick={() => {
|
||
router.push('/device')
|
||
}}
|
||
>
|
||
<Text className="text-[12px] font-[900]">{'吧唧管理'}</Text>
|
||
</Block>
|
||
)}
|
||
|
||
<Block
|
||
className={`border-2 border-black px-[16px] py-[6px] shadow-medium-black ${isSelectionMode ? 'bg-[#e61e25] text-white' : 'bg-accent text-black'}`}
|
||
onClick={onToggleSelectionMode}
|
||
>
|
||
<Text className="text-[12px] font-[900]">{isSelectionMode ? '取消' : '管理'}</Text>
|
||
</Block>
|
||
</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 inset-x-[16px] bottom-[96px] z-50" style={{ paddingBottom: insets.bottom }}>
|
||
<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="text-[14px] font-[900]">已选: {selectedCount}</Text>
|
||
<Block className="text-[12px] underline" onClick={onSelectAll}>
|
||
<Text className="text-[12px] font-[700]">全选</Text>
|
||
</Block>
|
||
</Block>
|
||
<Block
|
||
className={`skew-x-3 flex-row items-center space-x-[8px] border-2 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'}`}
|
||
onClick={onDelete}
|
||
>
|
||
<Ionicons color="black" name="trash-outline" size={16} />
|
||
<Text className="text-[14px] font-[900]">{selectedCount > 0 ? '删除' : '删除'}</Text>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
},
|
||
)
|
||
|
||
const FABButtons = memo(({ onGenAgain, handleSync }: { onGenAgain: () => void; handleSync: () => void }) => {
|
||
const insets = useSafeAreaInsets()
|
||
return (
|
||
<Block className="absolute inset-x-[16px] bottom-[96px] 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
|
||
className={cn`relative h-[56px] items-center justify-center overflow-hidden border-[2px] border-black bg-[#e61e25] shadow-large-accent`}
|
||
onClick={onGenAgain}
|
||
>
|
||
<Block className="z-10 skew-x-6 flex-row items-center gap-[8px]">
|
||
<MaterialCommunityIcons color="white" name="star-four-points" size={20} />
|
||
<Text className="font-[bol]d text-[16px] text-white">再次生成</Text>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
|
||
<Block className="flex-1 -skew-x-6 shadow-large-accent">
|
||
<Block
|
||
className={cn`relative h-[56px] items-center justify-center overflow-hidden border-[2px] border-black bg-black`}
|
||
onClick={handleSync}
|
||
>
|
||
<Block className="z-10 flex-row items-center gap-[8px]">
|
||
<Ionicons color={'white'} name="refresh" size={20} />
|
||
<Text className={cn`font-[bol]d bg-black text-[16px] text-white`}>同步</Text>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
// ============ 主要组件部分 ============
|
||
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 (
|
||
<Block className="relative z-40 flex-row items-center justify-between py-[12px]">
|
||
<Block className="flex-row items-center gap-[8px] border-2 border-white bg-black py-[4px] shadow-hard-accent">
|
||
<Block className={`size-[8px] ${connectedDevice ? 'bg-accent' : 'bg-red-500'}`} />
|
||
<Text className="text-[12px] font-[900] tracking-[1px] text-white">
|
||
{connectedDevice ? '设备已连接' : '设备离线'}
|
||
</Text>
|
||
</Block>
|
||
<Block className="flex-row">
|
||
<Block
|
||
className="!mr-[12px] h-[40px] flex-row items-center justify-center gap-[8px] border-[3px] border-black bg-white px-[12px] shadow-hard-black"
|
||
onClick={handleLogout}
|
||
>
|
||
<Text className="text-[12px] font-[900] text-black">{loginText}</Text>
|
||
</Block>
|
||
<Block
|
||
className="h-[40px] flex-row items-center justify-center gap-[8px] border-[3px] border-black bg-white px-[12px] shadow-hard-black"
|
||
onClick={onPick}
|
||
>
|
||
<Ionicons color="black" name="cloud-upload-outline" size={18} />
|
||
<Text className="text-[12px] font-[900] text-black">上传本地</Text>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
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 (
|
||
<Block className="relative z-10 items-center">
|
||
<Block className="relative my-[24px] flex w-full flex-row items-center justify-between">
|
||
<Block
|
||
className={`w-[48px] items-center justify-center gap-[4px] border-[3px] border-black py-[12px] shadow-hard-black-50 ${connectedDevice ? 'bg-accent' : 'bg-white'}`}
|
||
onClick={onStartConnect}
|
||
>
|
||
<Ionicons
|
||
color={connectedDevice ? 'black' : '#6B7280'}
|
||
name={connectedDevice ? 'wifi' : 'wifi-outline'}
|
||
size={20}
|
||
/>
|
||
<Text className="text-[10px] font-[900]">{connectedDevice ? '已连接' : '选择设备'}</Text>
|
||
</Block>
|
||
<Block className="relative size-[256px] items-center justify-center">
|
||
<Block
|
||
animated
|
||
className="absolute inset-[-12px] rounded-full border-[3px] border-dashed border-black opacity-30"
|
||
style={outerRingStyle}
|
||
/>
|
||
<Block>
|
||
<GalleryRenderer selectedItem={selectedItem} />
|
||
{/* {!connectedDevice?.id && (
|
||
<Block className="absolute inset-0 z-50 items-center justify-center">
|
||
<Text className="border border-white bg-black px-[12px] py-[2px] text-[12px] font-[900] text-white">
|
||
离线模式
|
||
</Text>
|
||
</Block>
|
||
)} */}
|
||
</Block>
|
||
</Block>
|
||
<Block className="h-[10px] w-[48px]" />
|
||
</Block>
|
||
</Block>
|
||
)
|
||
},
|
||
)
|
||
|
||
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 (
|
||
<Block className="rounded-full border-4 border-black">
|
||
<Block className="relative z-10" style={{ width: Width, height: Width, borderRadius: Width, overflow: 'hidden' }}>
|
||
<VideoBox style={{ width: Width, height: Width }} width={256} url={url} />
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
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 (
|
||
<Block className="absolute inset-0" style={{ zIndex: 100 }}>
|
||
<Block className="relative flex-1 bg-paper">
|
||
<Block className="flex-row items-center justify-between px-[16px] py-[12px]">
|
||
<Block
|
||
className="size-[40px] items-center justify-center border-[3px] border-black bg-white shadow-medium-black"
|
||
onClick={onBack}
|
||
>
|
||
<Ionicons color="black" name="chevron-back" size={24} />
|
||
</Block>
|
||
<Text className="text-[20px] font-[900] tracking-[-0.5px]">设备管理</Text>
|
||
<Block className="size-[40px] items-center justify-center bg-black/0">{/* 空占位符 */}</Block>
|
||
</Block>
|
||
|
||
<ScrollView className="relative flex-1 bg-paper">
|
||
<Block className="p-[16px]">
|
||
<Block className="mb-[16px] flex-row items-center gap-[12px] border-[3px] border-black bg-white p-[16px] shadow-medium-black">
|
||
<SpinningLoader />
|
||
<Text className="text-[14px] font-[900] text-black">正在扫描设备...</Text>
|
||
</Block>
|
||
{deviceList.map((device) => (
|
||
<DeviceItem key={device.id} device={device} />
|
||
))}
|
||
</Block>
|
||
<Block className="h-[200px] w-full" />
|
||
</ScrollView>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|