- 新增 FileTransferPage 组件,支持本地与远程文件传输 - 添加 LocalFilePanel 和 RemoteFilePanel 组件 - 实现 TransferQueue 传输队列组件,支持拖动调整高度 - 优化侧边栏拖动条样式,修复拖动偏移问题 - 统一文件列表样式为灰白极简风格 - 支持 file-transfer-panel 协议打开文件传输标签页
653 lines
24 KiB
TypeScript
653 lines
24 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { Monitor, GitBranch, Settings, Plus, Trash2, GripVertical, Folder, Code, Sparkles } from 'lucide-react'
|
||
import { ConfigDialog } from './components/ConfigDialog'
|
||
import { DeleteConfirmDialog } from '@/components/dialogs/DeleteConfirmDialog'
|
||
import type { DeviceInfo, RemoteConfig, RemoteDevice } from './types'
|
||
import { useTabContentContext, useScreenshot } from '@/stores'
|
||
import { getRemoteConfig, saveRemoteConfig, getDeviceData } from './api'
|
||
import type { FileItem } from '@/lib/api'
|
||
|
||
const defaultConfig: RemoteConfig = {
|
||
devices: [],
|
||
}
|
||
|
||
const createRemoteDesktopFileItem = (url: string, title: string, deviceName?: string): FileItem => {
|
||
const id = deviceName ? `remote-${deviceName}` : 'remote-desktop'
|
||
return {
|
||
name: title,
|
||
path: `remote-desktop://${id}?url=${encodeURIComponent(url)}&device=${encodeURIComponent(deviceName || '')}`,
|
||
type: 'file' as const,
|
||
size: 0,
|
||
modified: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
const createRemoteGitFileItem = (url: string, title: string, deviceName?: string): FileItem => {
|
||
const id = deviceName ? `remote-git-${deviceName}` : 'remote-git'
|
||
return {
|
||
name: title,
|
||
path: `remote-git://${id}?url=${encodeURIComponent(url)}&device=${encodeURIComponent(deviceName || '')}`,
|
||
type: 'file' as const,
|
||
size: 0,
|
||
modified: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
const createEmptyDevice = (): RemoteDevice => ({
|
||
id: Date.now().toString(),
|
||
deviceName: '',
|
||
serverHost: '',
|
||
desktopPort: 3000,
|
||
gitPort: 3001,
|
||
})
|
||
|
||
export const RemotePage: React.FC = () => {
|
||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null)
|
||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||
const [showConfig, setShowConfig] = useState(false)
|
||
const [config, setConfig] = useState<RemoteConfig>(defaultConfig)
|
||
const [loading, setLoading] = useState(true)
|
||
const [editingDevice, setEditingDevice] = useState<RemoteDevice | null>(null)
|
||
const [deviceLastConnected, setDeviceLastConnected] = useState<Record<string, string>>({})
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||
const [deletingDeviceId, setDeletingDeviceId] = useState<string | null>(null)
|
||
|
||
// 自定义拖拽状态
|
||
const dragRef = useRef<{
|
||
isDragging: boolean
|
||
startY: number
|
||
startIndex: number
|
||
currentIndex: number
|
||
listRect: DOMRect | null
|
||
itemRects: DOMRect[]
|
||
}>({
|
||
isDragging: false,
|
||
startY: 0,
|
||
startIndex: -1,
|
||
currentIndex: -1,
|
||
listRect: null,
|
||
itemRects: [],
|
||
})
|
||
const listContainerRef = useRef<HTMLDivElement>(null)
|
||
|
||
const { selectFile } = useTabContentContext()
|
||
const { screenshot, lastConnected, loadScreenshots } = useScreenshot()
|
||
|
||
const selectedConfig = config.devices.find(d => d.id === selectedDeviceId) || null
|
||
|
||
useEffect(() => {
|
||
const init = async () => {
|
||
try {
|
||
const savedConfig = await getRemoteConfig()
|
||
const configWithDevices = savedConfig.devices ? savedConfig : { devices: [] }
|
||
setConfig(configWithDevices)
|
||
if (configWithDevices.devices.length > 0 && !selectedDeviceId) {
|
||
setSelectedDeviceId(configWithDevices.devices[0].id)
|
||
}
|
||
// 加载所有设备的最后连接时间
|
||
const newData: Record<string, string> = {}
|
||
for (const device of configWithDevices.devices) {
|
||
if (device.deviceName) {
|
||
try {
|
||
const data = await getDeviceData(device.deviceName)
|
||
if (data?.lastConnected) {
|
||
newData[device.deviceName] = data.lastConnected
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load device data:', err)
|
||
}
|
||
}
|
||
}
|
||
setDeviceLastConnected(newData)
|
||
} catch (err) {
|
||
console.error('Failed to load remote config:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
init()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (selectedConfig?.deviceName) {
|
||
loadScreenshots(selectedConfig.deviceName)
|
||
}
|
||
}, [selectedDeviceId, selectedConfig?.deviceName, loadScreenshots])
|
||
|
||
// 当全局 lastConnected 更新时,同步更新 deviceLastConnected
|
||
useEffect(() => {
|
||
if (selectedConfig?.deviceName) {
|
||
setDeviceLastConnected(prev => ({
|
||
...prev,
|
||
[selectedConfig.deviceName]: lastConnected
|
||
}))
|
||
}
|
||
}, [lastConnected, selectedConfig?.deviceName])
|
||
|
||
// 当设备列表变化时,加载新增设备的最后连接时间
|
||
useEffect(() => {
|
||
const loadNewDevices = async () => {
|
||
for (const device of config.devices) {
|
||
if (device.deviceName && !deviceLastConnected[device.deviceName]) {
|
||
try {
|
||
const data = await getDeviceData(device.deviceName)
|
||
if (data?.lastConnected) {
|
||
setDeviceLastConnected(prev => ({
|
||
...prev,
|
||
[device.deviceName]: data.lastConnected
|
||
}))
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load device data:', err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!loading) {
|
||
loadNewDevices()
|
||
}
|
||
}, [config.devices, loading, deviceLastConnected])
|
||
|
||
const devices: DeviceInfo[] = (config.devices || []).map(device => ({
|
||
id: device.id,
|
||
name: device.deviceName,
|
||
status: device.serverHost ? 'online' as const : 'offline' as const,
|
||
lastConnected: deviceLastConnected[device.deviceName] || '-',
|
||
}))
|
||
|
||
const selectedDevice = devices.find(device => device.id === selectedDeviceId) || null
|
||
|
||
const existingDeviceNames = config.devices.map(d => d.deviceName)
|
||
|
||
const handleOpenRemoteDesktop = () => {
|
||
if (!selectedConfig?.serverHost) {
|
||
setShowConfig(true)
|
||
return
|
||
}
|
||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}`
|
||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||
const fileItem = createRemoteDesktopFileItem(url, `远程桌面${deviceName}`, selectedConfig.deviceName)
|
||
selectFile(fileItem)
|
||
}
|
||
|
||
const handleOpenGitRepo = () => {
|
||
if (!selectedConfig?.serverHost) {
|
||
setShowConfig(true)
|
||
return
|
||
}
|
||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.gitPort}`
|
||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||
const fileItem = createRemoteGitFileItem(url, `远程 Git 仓库${deviceName}`, selectedConfig.deviceName)
|
||
selectFile(fileItem)
|
||
}
|
||
|
||
const handleOpenFileTransfer = () => {
|
||
if (!selectedConfig?.serverHost) {
|
||
setShowConfig(true)
|
||
return
|
||
}
|
||
const url = `file-transfer-panel?host=${encodeURIComponent(selectedConfig.serverHost)}&port=${selectedConfig.desktopPort}`
|
||
const fileItem: FileItem = {
|
||
name: `文件传输 - ${selectedConfig.deviceName}`,
|
||
path: url,
|
||
type: 'file',
|
||
size: 0,
|
||
modified: new Date().toISOString(),
|
||
}
|
||
selectFile(fileItem)
|
||
}
|
||
|
||
const handleOpenCode = () => {
|
||
if (!selectedConfig?.serverHost) {
|
||
setShowConfig(true)
|
||
return
|
||
}
|
||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/opencode`
|
||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||
const fileItem = createRemoteDesktopFileItem(url, `OpenCode${deviceName}`, selectedConfig.deviceName)
|
||
selectFile(fileItem)
|
||
}
|
||
|
||
const handleOpenClaw = () => {
|
||
if (!selectedConfig?.serverHost) {
|
||
setShowConfig(true)
|
||
return
|
||
}
|
||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/openclaw`
|
||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||
const fileItem = createRemoteDesktopFileItem(url, `OpenClaw${deviceName}`, selectedConfig.deviceName)
|
||
selectFile(fileItem)
|
||
}
|
||
|
||
const handleOpenConfig = () => {
|
||
if (selectedConfig) {
|
||
setEditingDevice(selectedConfig)
|
||
} else {
|
||
setEditingDevice(createEmptyDevice())
|
||
}
|
||
setShowConfig(true)
|
||
}
|
||
|
||
const handleCloseConfig = () => {
|
||
setShowConfig(false)
|
||
setEditingDevice(null)
|
||
}
|
||
|
||
const handleSaveConfig = useCallback(async (device: RemoteDevice) => {
|
||
const existingIndex = config.devices.findIndex(d => d.id === device.id)
|
||
let newDevices: RemoteDevice[]
|
||
if (existingIndex >= 0) {
|
||
newDevices = [...config.devices]
|
||
newDevices[existingIndex] = device
|
||
} else {
|
||
newDevices = [...config.devices, device]
|
||
}
|
||
const newConfig = { devices: newDevices }
|
||
try {
|
||
await saveRemoteConfig(newConfig)
|
||
setConfig(newConfig)
|
||
setSelectedDeviceId(device.id)
|
||
} catch (err) {
|
||
console.error('Failed to save config:', err)
|
||
}
|
||
setShowConfig(false)
|
||
setEditingDevice(null)
|
||
}, [config.devices])
|
||
|
||
const handleAddDevice = () => {
|
||
setEditingDevice(createEmptyDevice())
|
||
setShowConfig(true)
|
||
}
|
||
|
||
const handleDeleteDevice = (deviceId: string) => {
|
||
setDeletingDeviceId(deviceId)
|
||
setDeleteDialogOpen(true)
|
||
}
|
||
|
||
const handleDeleteConfirm = async () => {
|
||
if (!deletingDeviceId) return
|
||
const newDevices = config.devices.filter(d => d.id !== deletingDeviceId)
|
||
const newConfig = { devices: newDevices }
|
||
try {
|
||
await saveRemoteConfig(newConfig)
|
||
setConfig(newConfig)
|
||
if (selectedDeviceId === deletingDeviceId) {
|
||
setSelectedDeviceId(newDevices.length > 0 ? newDevices[0].id : null)
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to delete device:', err)
|
||
}
|
||
setDeleteDialogOpen(false)
|
||
setDeletingDeviceId(null)
|
||
}
|
||
|
||
const handleDeleteCancel = () => {
|
||
setDeleteDialogOpen(false)
|
||
setDeletingDeviceId(null)
|
||
}
|
||
|
||
// 自定义拖拽处理 - 元素跟随鼠标移动 + 实时重排
|
||
const handleMouseDown = (e: React.MouseEvent, index: number) => {
|
||
// 只允许左键拖拽
|
||
if (e.button !== 0) return
|
||
|
||
// 阻止默认选择行为
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
|
||
const listContainer = listContainerRef.current
|
||
if (!listContainer) return
|
||
|
||
const listRect = listContainer.getBoundingClientRect()
|
||
|
||
// 获取被拖动元素的引用
|
||
const listContent = listContainer.querySelector('[data-drag-list]')
|
||
const items = listContent?.querySelectorAll('[data-drag-item]') as NodeListOf<HTMLElement> | undefined
|
||
const draggedElement = items?.[index]
|
||
|
||
dragRef.current = {
|
||
isDragging: true,
|
||
startY: e.clientY,
|
||
startIndex: index,
|
||
currentIndex: index,
|
||
listRect,
|
||
itemRects: [],
|
||
}
|
||
|
||
setDragIndex(index)
|
||
setDragOverIndex(index)
|
||
|
||
// 直接操作DOM实现流畅拖动
|
||
if (draggedElement) {
|
||
draggedElement.style.position = 'relative'
|
||
draggedElement.style.zIndex = '100'
|
||
draggedElement.style.transition = 'none'
|
||
}
|
||
|
||
// 添加全局鼠标事件
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
const drag = dragRef.current
|
||
if (!drag.isDragging || !drag.listRect) return
|
||
|
||
const { listRect, startIndex } = drag
|
||
|
||
// 检测鼠标是否在列表区域内
|
||
const isInListArea =
|
||
e.clientX >= listRect.left &&
|
||
e.clientX <= listRect.right &&
|
||
e.clientY >= listRect.top &&
|
||
e.clientY <= listRect.bottom
|
||
|
||
// 如果鼠标移出列表区域,取消拖拽
|
||
if (!isInListArea) {
|
||
handleMouseUp(e)
|
||
return
|
||
}
|
||
|
||
// 直接修改DOM实现流畅移动
|
||
const listContent = listContainerRef.current?.querySelector('[data-drag-list]')
|
||
const items = listContent?.querySelectorAll('[data-drag-item]') as NodeListOf<HTMLElement> | undefined
|
||
const draggedElement = items?.[drag.startIndex]
|
||
|
||
// 计算被拖动元素的偏移
|
||
const deltaY = e.clientY - drag.startY
|
||
|
||
if (draggedElement) {
|
||
draggedElement.style.transform = `translateY(${deltaY}px)`
|
||
}
|
||
|
||
const mouseY = e.clientY
|
||
|
||
// 简单计算:基于鼠标在列表中的相对位置,加24px偏移使落点在缝隙中间
|
||
const relativeY = mouseY - listRect.top - 8
|
||
let newIndex = Math.floor(relativeY / 48) - 1
|
||
newIndex = Math.max(0, Math.min(newIndex, devices.length - 1))
|
||
|
||
// 限制newIndex范围
|
||
if (newIndex < 0) newIndex = 0
|
||
if (newIndex >= devices.length) newIndex = devices.length - 1
|
||
|
||
drag.currentIndex = newIndex
|
||
setDragOverIndex(newIndex)
|
||
|
||
// 直接操作其他元素DOM实现重排(去掉transition)
|
||
if (items) {
|
||
const itemHeight = 48
|
||
items.forEach((item, i) => {
|
||
if (i === drag.startIndex) return // 跳过被拖动的元素
|
||
|
||
let transform = ''
|
||
if (drag.startIndex < newIndex) {
|
||
// 从上面拖到下面:原位置到目标位置之间的元素向上移动
|
||
if (i > drag.startIndex && i <= newIndex) {
|
||
transform = `translateY(-${itemHeight}px)`
|
||
}
|
||
} else if (drag.startIndex > newIndex) {
|
||
// 从下面拖到上面:目标位置到原位置之间的元素向下移动
|
||
if (i >= newIndex && i < drag.startIndex) {
|
||
transform = `translateY(${itemHeight}px)`
|
||
}
|
||
}
|
||
item.style.transform = transform
|
||
})
|
||
}
|
||
}
|
||
|
||
const handleMouseUp = async (e: MouseEvent) => {
|
||
const drag = dragRef.current
|
||
if (!drag.isDragging) return
|
||
|
||
drag.isDragging = false
|
||
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
|
||
// 清理所有元素的样式
|
||
const listContent = listContainerRef.current?.querySelector('[data-drag-list]')
|
||
const items = listContent?.querySelectorAll('[data-drag-item]') as NodeListOf<HTMLElement> | undefined
|
||
items?.forEach(item => {
|
||
item.style.transform = ''
|
||
item.style.position = ''
|
||
item.style.zIndex = ''
|
||
item.style.transition = ''
|
||
})
|
||
const draggedElement = items?.[drag.startIndex]
|
||
if (draggedElement) {
|
||
draggedElement.style.transform = ''
|
||
draggedElement.style.position = ''
|
||
draggedElement.style.zIndex = ''
|
||
}
|
||
|
||
const { startIndex, currentIndex } = drag
|
||
|
||
setDragIndex(null)
|
||
setDragOverIndex(null)
|
||
|
||
// 如果位置发生变化,保存新顺序
|
||
if (startIndex !== currentIndex && currentIndex >= 0) {
|
||
const newDevices = [...config.devices]
|
||
const [movedItem] = newDevices.splice(startIndex, 1)
|
||
newDevices.splice(currentIndex, 0, movedItem)
|
||
|
||
const newConfig = { devices: newDevices }
|
||
|
||
try {
|
||
await saveRemoteConfig(newConfig)
|
||
setConfig(newConfig)
|
||
} catch (err) {
|
||
console.error('Failed to save reordered config:', err)
|
||
}
|
||
}
|
||
|
||
dragRef.current = {
|
||
isDragging: false,
|
||
startY: 0,
|
||
startIndex: -1,
|
||
currentIndex: -1,
|
||
listRect: null,
|
||
itemRects: [],
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-gray-500 dark:text-gray-400">加载中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
<div className="pl-12 pt-6 pb-4 shrink-0">
|
||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2 whitespace-nowrap">
|
||
<Monitor className="w-8 h-8 shrink-0" />
|
||
远程
|
||
</h1>
|
||
</div>
|
||
|
||
<div className="flex-1 flex px-0 -ml-4">
|
||
<div className="absolute left-60 top-[88px] bottom-16 w-px bg-gray-300/40 dark:bg-gray-500/50" />
|
||
<div
|
||
ref={listContainerRef}
|
||
className="w-56 shrink-0 pr-4 overflow-hidden select-none"
|
||
>
|
||
<button
|
||
onClick={handleAddDevice}
|
||
className="w-full flex items-center gap-2 px-3 py-3 rounded-lg cursor-pointer transition-colors hover:bg-gray-100/50 dark:hover:bg-gray-700/40 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
||
>
|
||
<Plus size={16} className="shrink-0" />
|
||
<span className="text-base truncate">添加设备</span>
|
||
</button>
|
||
<div data-drag-list className="space-y-2 w-full relative mt-2">
|
||
{devices.map((device, index) => {
|
||
const isSelected = selectedDeviceId === device.id
|
||
const isDragging = dragIndex === index
|
||
|
||
return (
|
||
<div
|
||
key={device.id}
|
||
data-drag-item
|
||
className={`group flex items-center gap-2 px-3 py-3 rounded-lg transition-all w-full max-w-full ${
|
||
isSelected
|
||
? 'bg-gray-100/50 dark:bg-gray-700/40'
|
||
: 'hover:bg-gray-100/30 dark:hover:bg-gray-700/20'
|
||
} ${isDragging ? 'opacity-90' : ''}`}
|
||
onClick={() => dragIndex === null && setSelectedDeviceId(device.id)}
|
||
>
|
||
<span
|
||
onMouseDown={(e) => handleMouseDown(e, index)}
|
||
className="cursor-grab active:cursor-grabbing p-0.5 hover:bg-gray-200/50 dark:hover:bg-gray-600/50 rounded"
|
||
>
|
||
<GripVertical size={14} className="text-gray-400 shrink-0" />
|
||
</span>
|
||
<span
|
||
className={`w-2 h-2 rounded-full shrink-0 ${device.status === 'online' ? 'bg-green-500' : 'bg-gray-400'
|
||
}`}
|
||
/>
|
||
<span className="text-base text-gray-700 dark:text-gray-300 truncate flex-1">
|
||
{device.name || '未命名设备'}
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleDeleteDevice(device.id)
|
||
}}
|
||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-all"
|
||
>
|
||
<Trash2 size={14} className="text-red-500" />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{devices.length === 0 && (
|
||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||
暂无设备,点击上方添加
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 pl-8 -mt-10 min-w-[400px] overflow-auto pb-6">
|
||
{selectedDevice && selectedConfig ? (
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center justify-between py-0 px-4 shrink-0 border-b border-gray-300/40 dark:border-gray-500/50">
|
||
<div className="flex items-center gap-4">
|
||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200 whitespace-nowrap">
|
||
{selectedDevice.name || '未命名设备'}
|
||
</h2>
|
||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||
<span className="flex items-center gap-1">
|
||
<span
|
||
className={`w-1.5 h-1.5 rounded-full ${selectedDevice.status === 'online' ? 'bg-green-500' : 'bg-gray-400'
|
||
}`}
|
||
/>
|
||
{selectedDevice.status === 'online' ? '在线' : '离线'}
|
||
</span>
|
||
<span>最后连接:{selectedDevice.lastConnected}</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={handleOpenConfig}
|
||
className="p-2 rounded-lg transition-colors hover:bg-gray-100/50 dark:hover:bg-gray-700/40 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||
title="配置"
|
||
>
|
||
<Settings size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-6 mb-4">
|
||
<div className="grid grid-cols-[2fr_1fr_1fr] gap-8">
|
||
<button
|
||
onClick={handleOpenRemoteDesktop}
|
||
className="relative flex flex-col rounded-2xl overflow-hidden border-2 border-gray-300 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-400 bg-white dark:bg-gray-800 shadow-sm hover:shadow-md transition-all cursor-pointer"
|
||
>
|
||
<div className="relative">
|
||
<img
|
||
src={screenshot || '/background.png'}
|
||
alt="远程桌面"
|
||
className="w-full aspect-video object-cover"
|
||
/>
|
||
<div className="absolute bottom-0 left-0 right-0 py-2 bg-gradient-to-t from-black/50 to-transparent text-lg font-medium text-white text-center">
|
||
远程桌面
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
<div className="grid grid-rows-2 gap-6">
|
||
<button
|
||
onClick={handleOpenGitRepo}
|
||
className="flex flex-col items-center justify-center gap-2 text-lg bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-2xl shadow-sm hover:shadow-md transition-all font-medium text-gray-700 dark:text-gray-200"
|
||
>
|
||
<GitBranch size={28} className="text-gray-600 dark:text-gray-300" />
|
||
<span>Git 仓库</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={handleOpenFileTransfer}
|
||
className="flex flex-col items-center justify-center gap-2 text-lg bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-2xl shadow-sm hover:shadow-md transition-all font-medium text-gray-700 dark:text-gray-200"
|
||
>
|
||
<Folder size={28} className="text-gray-600 dark:text-gray-300" />
|
||
<span>文件传输</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-rows-2 gap-6">
|
||
<button
|
||
onClick={handleOpenCode}
|
||
className="flex flex-col items-center justify-center gap-2 text-lg bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-2xl shadow-sm hover:shadow-md transition-all font-medium text-gray-700 dark:text-gray-200"
|
||
>
|
||
<Code size={28} className="text-gray-600 dark:text-gray-300" />
|
||
<span>OpenCode</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={handleOpenClaw}
|
||
className="flex flex-col items-center justify-center gap-2 text-lg bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-2xl shadow-sm hover:shadow-md transition-all font-medium text-gray-700 dark:text-gray-200"
|
||
>
|
||
<Sparkles size={28} className="text-gray-600 dark:text-gray-300" />
|
||
<span>OpenClaw</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||
请选择设备
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{editingDevice && (
|
||
<ConfigDialog
|
||
isOpen={showConfig}
|
||
onClose={handleCloseConfig}
|
||
config={editingDevice}
|
||
onSave={handleSaveConfig}
|
||
existingDeviceNames={existingDeviceNames.filter(name => name !== editingDevice.deviceName)}
|
||
isEdit={!!config.devices.find(d => d.id === editingDevice.id)}
|
||
/>
|
||
)}
|
||
|
||
{deletingDeviceId && (
|
||
<DeleteConfirmDialog
|
||
isOpen={deleteDialogOpen}
|
||
title="删除设备"
|
||
message={`确定要删除设备 "${config.devices.find(d => d.id === deletingDeviceId)?.deviceName || '未命名设备'}" 吗?`}
|
||
expectedText="DELETE DEVICE"
|
||
onConfirm={handleDeleteConfirm}
|
||
onCancel={handleDeleteCancel}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|