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

1011 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
})