Files
XCDesktop/src/modules/remote/RemotePage.tsx
ssdfasd afe43c5ff9 feat(remote): 添加文件传输功能页面
- 新增 FileTransferPage 组件,支持本地与远程文件传输
- 添加 LocalFilePanel 和 RemoteFilePanel 组件
- 实现 TransferQueue 传输队列组件,支持拖动调整高度
- 优化侧边栏拖动条样式,修复拖动偏移问题
- 统一文件列表样式为灰白极简风格
- 支持 file-transfer-panel 协议打开文件传输标签页
2026-03-08 17:03:21 +08:00

653 lines
24 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 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>
)
}