Initial commit
This commit is contained in:
602
src/modules/remote/RemotePage.tsx
Normal file
602
src/modules/remote/RemotePage.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Monitor, GitBranch, Settings, Plus, Trash2, GripVertical, Folder } 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 = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/files`
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteDesktopFileItem(url, `文件传输${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-1 md:grid-cols-[2fr_1fr] gap-8">
|
||||
<button
|
||||
onClick={handleOpenRemoteDesktop}
|
||||
className="rounded-lg overflow-hidden border border-gray-300/40 dark:border-gray-500/50 bg-gray-100/30 dark:bg-gray-800/30 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={screenshot || '/background.png'}
|
||||
alt="远程桌面"
|
||||
className="w-full aspect-video object-cover"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="grid grid-rows-2 gap-6">
|
||||
<button
|
||||
onClick={handleOpenGitRepo}
|
||||
className="flex items-center justify-center gap-2 text-2xl bg-gray-100/50 dark:bg-gray-700/40 hover:bg-gray-200/60 dark:hover:bg-gray-600/50 rounded-2xl shadow-md hover:shadow-xl transition-all font-medium text-gray-700 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
<GitBranch size={24} />
|
||||
Git 仓库
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleOpenFileTransfer}
|
||||
className="flex items-center justify-center gap-2 text-2xl bg-gray-100/50 dark:bg-gray-700/40 hover:bg-gray-200/60 dark:hover:bg-gray-600/50 rounded-2xl shadow-md hover:shadow-xl transition-all font-medium text-gray-700 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
<Folder size={24} />
|
||||
文件传输
|
||||
</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>
|
||||
)
|
||||
}
|
||||
18
src/modules/remote/RemoteTabPage.tsx
Normal file
18
src/modules/remote/RemoteTabPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { RemoteWebView } from './components/RemoteWebView'
|
||||
|
||||
interface RemoteTabPageProps {
|
||||
url: string
|
||||
title: string
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
export const RemoteTabPage: React.FC<RemoteTabPageProps> = ({ url, title, deviceName = '' }) => {
|
||||
const isDesktop = title.includes('远程桌面')
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<RemoteWebView url={url} title={title} isDesktop={isDesktop} deviceName={deviceName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/modules/remote/api.ts
Normal file
39
src/modules/remote/api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getModuleApi } from '@/lib/module-registry'
|
||||
import { type RemoteEndpoints } from '@shared/modules/remote'
|
||||
import type { RemoteConfig } from './types'
|
||||
|
||||
const getApi = () => {
|
||||
const api = getModuleApi<RemoteEndpoints>('remote')
|
||||
if (!api) {
|
||||
throw new Error('remote module API not initialized')
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
export const getRemoteConfig = async (): Promise<RemoteConfig> => {
|
||||
return (await getApi().get<RemoteConfig>('getConfig'))!
|
||||
}
|
||||
|
||||
export const saveRemoteConfig = async (config: RemoteConfig): Promise<void> => {
|
||||
await getApi().post<null>('saveConfig', config)
|
||||
}
|
||||
|
||||
export const getScreenshot = async (deviceName?: string): Promise<string> => {
|
||||
const params = deviceName ? { device: deviceName } : undefined
|
||||
const result = await getApi().get<string>('getScreenshot', params ? { queryParams: params } : undefined)
|
||||
return result || ''
|
||||
}
|
||||
|
||||
export const saveScreenshot = async (dataUrl: string, deviceName?: string): Promise<void> => {
|
||||
await getApi().post<null>('saveScreenshot', { dataUrl, deviceName })
|
||||
}
|
||||
|
||||
export const getDeviceData = async (deviceName?: string): Promise<{ lastConnected?: string } | null> => {
|
||||
const params = deviceName ? { device: deviceName } : undefined
|
||||
const result = await getApi().get<{ lastConnected?: string } | null>('getData', params ? { queryParams: params } : undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
export const saveDeviceData = async (deviceName: string, lastConnected: string): Promise<void> => {
|
||||
await getApi().post<null>('saveData', { deviceName, lastConnected })
|
||||
}
|
||||
179
src/modules/remote/components/ConfigDialog.tsx
Normal file
179
src/modules/remote/components/ConfigDialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { RemoteDevice } from '../types'
|
||||
|
||||
interface ConfigDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
config: RemoteDevice
|
||||
onSave: (device: RemoteDevice) => void
|
||||
existingDeviceNames: string[]
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
export const ConfigDialog: React.FC<ConfigDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
config,
|
||||
onSave,
|
||||
existingDeviceNames,
|
||||
isEdit = false,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<RemoteDevice>(config)
|
||||
const [nameError, setNameError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(config)
|
||||
setNameError('')
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const handleChange = (field: keyof RemoteDevice, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}))
|
||||
if (field === 'deviceName') {
|
||||
setNameError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = formData.deviceName.trim()
|
||||
if (!trimmedName) {
|
||||
setNameError('请输入设备名称')
|
||||
return
|
||||
}
|
||||
const isDuplicate = existingDeviceNames.some(
|
||||
(name) => name.toLowerCase() === trimmedName.toLowerCase() &&
|
||||
(!isEdit || name.toLowerCase() !== config.deviceName.toLowerCase())
|
||||
)
|
||||
if (isDuplicate) {
|
||||
setNameError('设备名称已存在')
|
||||
return
|
||||
}
|
||||
onSave({ ...formData, deviceName: trimmedName })
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl transform transition-all animate-in zoom-in-95 duration-200 w-full max-w-md"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
远程配置
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
设备名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.deviceName}
|
||||
onChange={(e) => handleChange('deviceName', e.target.value)}
|
||||
placeholder="例如: 开发服务器"
|
||||
className={`w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors ${nameError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="mt-1 text-sm text-red-500">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
服务器地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serverHost}
|
||||
onChange={(e) => handleChange('serverHost', e.target.value)}
|
||||
placeholder="例如: 192.168.1.100"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
远程桌面端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.desktopPort}
|
||||
onChange={(e) => handleChange('desktopPort', parseInt(e.target.value) || 3000)}
|
||||
placeholder="默认: 3000"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
远程 Git 端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.gitPort}
|
||||
onChange={(e) => handleChange('gitPort', parseInt(e.target.value) || 3001)}
|
||||
placeholder="默认: 3001"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
514
src/modules/remote/components/RemoteWebView.tsx
Normal file
514
src/modules/remote/components/RemoteWebView.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { useWallpaper, useScreenshot } from '@/stores'
|
||||
|
||||
interface RemoteWebViewProps {
|
||||
url: string
|
||||
title: string
|
||||
isDesktop?: boolean
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
const CAPTURE_INTERVAL = 60000
|
||||
|
||||
// WebSocket 消息类型
|
||||
const MessageTypes = {
|
||||
CLIPBOARD_GET: 'clipboardGet',
|
||||
CLIPBOARD_SET: 'clipboardSet',
|
||||
CLIPBOARD_DATA: 'clipboardData',
|
||||
CLIPBOARD_RESULT: 'clipboardResult',
|
||||
CLIPBOARD_TOO_LARGE: 'clipboardTooLarge'
|
||||
}
|
||||
|
||||
export const RemoteWebView: React.FC<RemoteWebViewProps> = ({ url, title, isDesktop = false, deviceName = '' }) => {
|
||||
const { opacity } = useWallpaper()
|
||||
const { updateScreenshot } = useScreenshot()
|
||||
const webviewRef = useRef<HTMLWebViewElement & {
|
||||
capturePage: () => Promise<{ resize: (opts: { width: number; height: number; quality: string }) => { toDataURL: () => string } }>
|
||||
}>(null)
|
||||
const deviceNameRef = useRef(deviceName)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [canGoBack, setCanGoBack] = useState(false)
|
||||
const [canGoForward, setCanGoForward] = useState(false)
|
||||
const captureIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isDomReadyRef = useRef(false)
|
||||
|
||||
// 剪贴板相关状态 - 用 ref 避免闭包问题
|
||||
const clipboardWsRef = useRef<WebSocket | null>(null)
|
||||
const [clipboardStatus, setClipboardStatus] = useState<string>('')
|
||||
|
||||
// 生成 WebSocket URL(不需要密码,WebSocket 允许未认证连接)
|
||||
const getWsUrl = useCallback(() => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const wsProtocol = urlObj.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${wsProtocol}//${urlObj.host}/ws`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [url])
|
||||
|
||||
// 初始化剪贴板 WebSocket 连接
|
||||
const initClipboardWs = useCallback(() => {
|
||||
const wsUrl = getWsUrl()
|
||||
if (!wsUrl) return null
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket connected')
|
||||
setClipboardStatus('已连接')
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket closed:', e.code, e.reason)
|
||||
clipboardWsRef.current = null
|
||||
setClipboardStatus('')
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[RemoteWebView] Clipboard WebSocket error:', e)
|
||||
setClipboardStatus('连接错误')
|
||||
}
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === MessageTypes.CLIPBOARD_DATA) {
|
||||
if (msg.contentType === 'text' && msg.data) {
|
||||
try {
|
||||
const result = await window.electronAPI?.clipboardWriteText(msg.data)
|
||||
if (result?.success) {
|
||||
console.log('[RemoteWebView] Clipboard synced from remote')
|
||||
setClipboardStatus('已同步到本地')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
} else {
|
||||
throw new Error(result?.error || 'write failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to write local clipboard:', err)
|
||||
setClipboardStatus('写入本地失败')
|
||||
}
|
||||
}
|
||||
} else if (msg.type === MessageTypes.CLIPBOARD_RESULT) {
|
||||
if (msg.success) {
|
||||
console.log('[RemoteWebView] Clipboard synced to remote')
|
||||
setClipboardStatus('已同步到远程')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
} else {
|
||||
setClipboardStatus('同步到远程失败')
|
||||
}
|
||||
} else if (msg.type === MessageTypes.CLIPBOARD_TOO_LARGE) {
|
||||
console.warn('[RemoteWebView] Remote clipboard too large:', msg.size)
|
||||
setClipboardStatus('内容过大')
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
return ws
|
||||
}, [getWsUrl])
|
||||
|
||||
// 同步剪贴板到远程
|
||||
const syncToRemote = useCallback(async () => {
|
||||
console.log('[RemoteWebView] syncToRemote called')
|
||||
|
||||
// 先关闭旧连接
|
||||
if (clipboardWsRef.current) {
|
||||
clipboardWsRef.current.close()
|
||||
clipboardWsRef.current = null
|
||||
}
|
||||
|
||||
const ws = initClipboardWs()
|
||||
if (!ws) {
|
||||
console.log('[RemoteWebView] initClipboardWs returned null')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
clipboardWsRef.current = ws
|
||||
|
||||
// 等待连接打开
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkOpen = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}
|
||||
}, 50)
|
||||
// 超时 3 秒
|
||||
setTimeout(() => {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[RemoteWebView] WebSocket not open')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.clipboardReadText()
|
||||
if (!result?.success) {
|
||||
console.log('[RemoteWebView] clipboard empty or read failed:', result?.error)
|
||||
setClipboardStatus(result?.error || '剪贴板为空')
|
||||
return
|
||||
}
|
||||
const text = result.text
|
||||
if (!text) {
|
||||
console.log('[RemoteWebView] clipboard empty')
|
||||
setClipboardStatus('剪贴板为空')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RemoteWebView] sending to remote:', text.substring(0, 50))
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.CLIPBOARD_SET,
|
||||
contentType: 'text',
|
||||
data: text
|
||||
}))
|
||||
setClipboardStatus('已发送到远程')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to read local clipboard:', err)
|
||||
setClipboardStatus('读取本地失败')
|
||||
}
|
||||
}, [initClipboardWs])
|
||||
|
||||
// 从远程获取剪贴板
|
||||
const syncFromRemote = useCallback(async () => {
|
||||
console.log('[RemoteWebView] syncFromRemote called')
|
||||
|
||||
// 先关闭旧连接
|
||||
if (clipboardWsRef.current) {
|
||||
clipboardWsRef.current.close()
|
||||
clipboardWsRef.current = null
|
||||
}
|
||||
|
||||
const ws = initClipboardWs()
|
||||
if (!ws) {
|
||||
console.log('[RemoteWebView] initClipboardWs returned null')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
clipboardWsRef.current = ws
|
||||
|
||||
// 等待连接打开
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkOpen = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}
|
||||
}, 50)
|
||||
// 超时 3 秒
|
||||
setTimeout(() => {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[RemoteWebView] WebSocket not open')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RemoteWebView] requesting from remote')
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.CLIPBOARD_GET
|
||||
}))
|
||||
}, [initClipboardWs])
|
||||
|
||||
// 保持 WebSocket 长连接,用于接收远程推送的剪贴板
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return
|
||||
|
||||
const wsUrl = getWsUrl()
|
||||
if (!wsUrl) return
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket connected for receiving')
|
||||
setClipboardStatus('已连接')
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket closed:', e.code)
|
||||
setClipboardStatus('')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setClipboardStatus('连接错误')
|
||||
}
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === MessageTypes.CLIPBOARD_DATA) {
|
||||
if (msg.contentType === 'text' && msg.data) {
|
||||
try {
|
||||
const result = await window.electronAPI?.clipboardWriteText(msg.data)
|
||||
if (result?.success) {
|
||||
console.log('[RemoteWebView] Clipboard synced from remote (auto)')
|
||||
setClipboardStatus('已同步到本地')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to write local clipboard:', err)
|
||||
setClipboardStatus('写入本地失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
clipboardWsRef.current = ws
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
}, [isDesktop])
|
||||
|
||||
// 清理 WebSocket
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clipboardWsRef.current) {
|
||||
clipboardWsRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 保持 deviceName 的最新引用
|
||||
useEffect(() => {
|
||||
deviceNameRef.current = deviceName
|
||||
}, [deviceName])
|
||||
|
||||
const captureScreenshot = useCallback(async () => {
|
||||
if (!isDesktop || !deviceName) return
|
||||
const webview = webviewRef.current
|
||||
if (!webview || !isDomReadyRef.current) return
|
||||
|
||||
try {
|
||||
const image = await webview.capturePage()
|
||||
if (!image) {
|
||||
console.warn('[RemoteWebView] capturePage returned empty image')
|
||||
return
|
||||
}
|
||||
const resizedImage = image.resize({ width: 640, height: 360, quality: 'good' })
|
||||
const dataUrl = resizedImage.toDataURL()
|
||||
if (!dataUrl || dataUrl.length === 0) {
|
||||
console.warn('[RemoteWebView] dataUrl is empty')
|
||||
return
|
||||
}
|
||||
await updateScreenshot(dataUrl, deviceName)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to capture screenshot:', err)
|
||||
}
|
||||
}, [isDesktop, deviceName, updateScreenshot])
|
||||
|
||||
const startCaptureInterval = useCallback(() => {
|
||||
if (!isDesktop) return
|
||||
if (captureIntervalRef.current) {
|
||||
clearInterval(captureIntervalRef.current)
|
||||
}
|
||||
captureIntervalRef.current = setInterval(captureScreenshot, CAPTURE_INTERVAL)
|
||||
}, [captureScreenshot, isDesktop])
|
||||
|
||||
const stopCaptureInterval = useCallback(() => {
|
||||
if (captureIntervalRef.current) {
|
||||
clearInterval(captureIntervalRef.current)
|
||||
captureIntervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateNavigationState = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
if (webview) {
|
||||
setCanGoBack(webview.canGoBack())
|
||||
setCanGoForward(webview.canGoForward())
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) return
|
||||
|
||||
const handleDidStartLoading = () => setLoading(true)
|
||||
const handleDidStopLoading = () => setLoading(false)
|
||||
const handleDidNavigate = () => {
|
||||
updateNavigationState()
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
const currentDeviceName = deviceNameRef.current
|
||||
if (isDesktop && isDomReadyRef.current && currentDeviceName) {
|
||||
stopCaptureInterval()
|
||||
try {
|
||||
const image = await webview.capturePage()
|
||||
if (!image) {
|
||||
console.warn('[RemoteWebView] capturePage returned empty image on close event')
|
||||
return
|
||||
}
|
||||
const resizedImage = image.resize({ width: 640, height: 360, quality: 'good' })
|
||||
const dataUrl = resizedImage.toDataURL()
|
||||
if (!dataUrl || dataUrl.length === 0) {
|
||||
console.warn('[RemoteWebView] dataUrl is empty on close event')
|
||||
return
|
||||
}
|
||||
console.log('[RemoteWebView] Saving screenshot on close event for device:', currentDeviceName)
|
||||
await updateScreenshot(dataUrl, currentDeviceName)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to capture screenshot on close event:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webview.addEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.addEventListener('did-stop-loading', handleDidStopLoading)
|
||||
webview.addEventListener('did-navigate', handleDidNavigate)
|
||||
webview.addEventListener('did-navigate-in-page', handleDidNavigate)
|
||||
webview.addEventListener('close', handleClose)
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.removeEventListener('did-stop-loading', handleDidStopLoading)
|
||||
webview.removeEventListener('did-navigate', handleDidNavigate)
|
||||
webview.removeEventListener('did-navigate-in-page', handleDidNavigate)
|
||||
webview.removeEventListener('close', handleClose)
|
||||
}
|
||||
}, [updateNavigationState, updateScreenshot, isDesktop])
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) return
|
||||
|
||||
const handleDomReady = () => {
|
||||
isDomReadyRef.current = true
|
||||
setTimeout(captureScreenshot, 2000)
|
||||
startCaptureInterval()
|
||||
}
|
||||
|
||||
webview.addEventListener('dom-ready', handleDomReady)
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('dom-ready', handleDomReady)
|
||||
}
|
||||
}, [captureScreenshot, startCaptureInterval])
|
||||
|
||||
// 组件卸载时保存截图
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 组件卸载时尝试保存截图
|
||||
const currentDeviceName = deviceNameRef.current
|
||||
if (isDesktop && isDomReadyRef.current && currentDeviceName) {
|
||||
const webview = webviewRef.current
|
||||
if (webview) {
|
||||
try {
|
||||
// 同步尝试捕获并保存
|
||||
const captureAndSave = async () => {
|
||||
try {
|
||||
const image = await webview.capturePage()
|
||||
if (!image) {
|
||||
console.warn('[RemoteWebView] capturePage returned empty image on unmount')
|
||||
return
|
||||
}
|
||||
const resizedImage = image.resize({ width: 640, height: 360, quality: 'good' })
|
||||
const dataUrl = resizedImage.toDataURL()
|
||||
if (!dataUrl || dataUrl.length === 0) {
|
||||
console.warn('[RemoteWebView] dataUrl is empty on unmount')
|
||||
return
|
||||
}
|
||||
console.log('[RemoteWebView] Saving screenshot on unmount for device:', currentDeviceName)
|
||||
await updateScreenshot(dataUrl, currentDeviceName)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to capture screenshot on unmount:', err)
|
||||
}
|
||||
}
|
||||
captureAndSave()
|
||||
} catch (err) {
|
||||
console.warn('[RemoteWebView] Sync capture failed on unmount:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isDesktop, updateScreenshot])
|
||||
|
||||
// 自动同步剪贴板到远程(当本地剪贴板变化时)
|
||||
const syncToRemoteWithText = useCallback(async (text: string) => {
|
||||
if (!text) return
|
||||
|
||||
const wsUrl = getWsUrl()
|
||||
if (!wsUrl) return
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.CLIPBOARD_SET,
|
||||
contentType: 'text',
|
||||
data: text
|
||||
}))
|
||||
setClipboardStatus('已自动同步到远程')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
setTimeout(() => ws.close(), 1000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
}, [getWsUrl])
|
||||
|
||||
// 监听全局快捷键和自动同步事件
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return
|
||||
|
||||
const cleanupToRemote = window.electronAPI?.onRemoteClipboardSyncToRemote(() => {
|
||||
syncToRemote()
|
||||
})
|
||||
|
||||
const cleanupFromRemote = window.electronAPI?.onRemoteClipboardSyncFromRemote(() => {
|
||||
syncFromRemote()
|
||||
})
|
||||
|
||||
const cleanupAutoSync = window.electronAPI?.onRemoteClipboardAutoSync((text) => {
|
||||
console.log('[RemoteWebView] Auto clipboard sync triggered')
|
||||
syncToRemoteWithText(text)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupToRemote?.()
|
||||
cleanupFromRemote?.()
|
||||
cleanupAutoSync?.()
|
||||
}
|
||||
}, [isDesktop, syncFromRemote, syncToRemote, syncToRemoteWithText])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: `rgba(var(--app-content-bg-rgb), ${opacity})` }}>
|
||||
<webview
|
||||
ref={webviewRef}
|
||||
src={url}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
{...{ allowpopups: true, webpreferences: 'contextIsolation=no' } as React.HTMLAttributes<HTMLWebViewElement>}
|
||||
/>
|
||||
{/* 剪贴板同步状态提示 */}
|
||||
{isDesktop && (
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 z-10">
|
||||
{clipboardStatus && (
|
||||
<div className="px-3 py-1.5 bg-gray-800/90 text-white text-xs rounded-full whitespace-nowrap">
|
||||
{clipboardStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { LocalFilePanel } from './LocalFilePanel'
|
||||
import { RemoteFilePanel } from './RemoteFilePanel'
|
||||
import { TransferQueue } from './TransferQueue'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote } from '../../api'
|
||||
import type { TransferItem } from '../../types'
|
||||
|
||||
interface FileTransferPanelProps {
|
||||
serverHost: string
|
||||
port: number
|
||||
password?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const FileTransferPanel: React.FC<FileTransferPanelProps> = ({
|
||||
serverHost,
|
||||
port,
|
||||
password,
|
||||
onClose,
|
||||
}) => {
|
||||
const [localSelected, setLocalSelected] = React.useState<FileItem | null>(null)
|
||||
const [remoteSelected, setRemoteSelected] = React.useState<RemoteFileItem | null>(null)
|
||||
const [transfers, setTransfers] = React.useState<TransferItem[]>([])
|
||||
const [transferring, setTransferring] = React.useState(false)
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!localSelected || !localSelected.path) return
|
||||
|
||||
setTransferring(true)
|
||||
const transferId = Date.now().toString()
|
||||
const newTransfer: TransferItem = {
|
||||
id: transferId,
|
||||
name: localSelected.name,
|
||||
type: 'upload',
|
||||
size: localSelected.size,
|
||||
progress: 0,
|
||||
status: 'transferring',
|
||||
}
|
||||
setTransfers((prev) => [...prev, newTransfer])
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/content?path=${encodeURIComponent(localSelected.path)}`)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], localSelected.name, { type: blob.type })
|
||||
|
||||
await uploadFileToRemote(serverHost, port, file, '', password, (progress) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
||||
)
|
||||
})
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId ? { ...t, progress: 100, status: 'completed' } : t
|
||||
)
|
||||
)
|
||||
setLocalSelected(null)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: 'error', error: err instanceof Error ? err.message : '上传失败' }
|
||||
: t
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setTransferring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!remoteSelected) return
|
||||
|
||||
setTransferring(true)
|
||||
const transferId = Date.now().toString()
|
||||
const newTransfer: TransferItem = {
|
||||
id: transferId,
|
||||
name: remoteSelected.name,
|
||||
type: 'download',
|
||||
size: remoteSelected.size,
|
||||
progress: 0,
|
||||
status: 'transferring',
|
||||
}
|
||||
setTransfers((prev) => [...prev, newTransfer])
|
||||
|
||||
try {
|
||||
await downloadFileFromRemote(serverHost, port, remoteSelected.name, '', password, (progress) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
||||
)
|
||||
})
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId ? { ...t, progress: 100, status: 'completed' } : t
|
||||
)
|
||||
)
|
||||
setRemoteSelected(null)
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err)
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: 'error', error: err instanceof Error ? err.message : '下载失败' }
|
||||
: t
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setTransferring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearTransfers = () => {
|
||||
setTransfers((prev) => prev.filter((t) => t.status === 'transferring'))
|
||||
}
|
||||
|
||||
const handleRemoveTransfer = (id: string) => {
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-[900px] h-[600px] max-w-[95vw] max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
文件传输
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex gap-4 p-4 overflow-hidden">
|
||||
<div className="flex-1 min-w-0">
|
||||
<LocalFilePanel
|
||||
selectedFile={localSelected}
|
||||
onSelect={setLocalSelected}
|
||||
onUpload={handleUpload}
|
||||
disabled={transferring}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<RemoteFilePanel
|
||||
serverHost={serverHost}
|
||||
port={port}
|
||||
password={password}
|
||||
selectedFile={remoteSelected}
|
||||
onSelect={setRemoteSelected}
|
||||
onDownload={handleDownload}
|
||||
disabled={transferring}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransferQueue
|
||||
transfers={transfers}
|
||||
onClear={handleClearTransfers}
|
||||
onRemove={handleRemoveTransfer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/modules/remote/components/file-transfer/LocalFilePanel.tsx
Normal file
154
src/modules/remote/components/file-transfer/LocalFilePanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
import { Folder, FileText, ArrowUp, ChevronRight, ChevronLeft, RefreshCw, HardDrive } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
import { fetchSystemFiles } from '@/lib/api'
|
||||
|
||||
interface LocalFilePanelProps {
|
||||
selectedFile: FileItem | null
|
||||
onSelect: (file: FileItem | null) => void
|
||||
onUpload: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const LocalFilePanel: React.FC<LocalFilePanelProps> = ({
|
||||
selectedFile,
|
||||
onSelect,
|
||||
onUpload,
|
||||
disabled,
|
||||
}) => {
|
||||
const [currentPath, setCurrentPath] = React.useState('')
|
||||
const [files, setFiles] = React.useState<FileItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [pathHistory, setPathHistory] = React.useState<string[]>([''])
|
||||
|
||||
const loadFiles = React.useCallback(async (systemPath: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchSystemFiles(systemPath)
|
||||
setFiles(items)
|
||||
} catch (err) {
|
||||
console.error('Failed to load local files:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadFiles(currentPath)
|
||||
}, [currentPath, loadFiles])
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (pathHistory.length > 1) {
|
||||
const newHistory = [...pathHistory]
|
||||
newHistory.pop()
|
||||
const prevPath = newHistory[newHistory.length - 1]
|
||||
setPathHistory(newHistory)
|
||||
setCurrentPath(prevPath)
|
||||
onSelect(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoInto = (folder: FileItem) => {
|
||||
setPathHistory([...pathHistory, folder.path])
|
||||
setCurrentPath(folder.path)
|
||||
onSelect(null)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadFiles(currentPath)
|
||||
}
|
||||
|
||||
const getDisplayPath = () => {
|
||||
if (!currentPath) return '本地文件'
|
||||
if (currentPath === '' || currentPath === '/') return '根目录'
|
||||
const parts = currentPath.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || currentPath
|
||||
}
|
||||
|
||||
const getFullPathDisplay = () => {
|
||||
if (!currentPath) return ''
|
||||
return currentPath
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
disabled={pathHistory.length <= 1}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="返回上级"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-[120px]">
|
||||
{getDisplayPath()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw size={14} className={clsx(loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
空文件夹
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => onSelect(file.type === 'dir' ? null : file)}
|
||||
onDoubleClick={() => file.type === 'dir' && handleGoInto(file)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors',
|
||||
selectedFile?.path === file.path
|
||||
? 'bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
||||
)}
|
||||
>
|
||||
{file.type === 'dir' ? (
|
||||
<Folder size={16} className="text-yellow-500 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText size={16} className="text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">
|
||||
{file.name}
|
||||
</span>
|
||||
{selectedFile?.path === file.path && (
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={!selectedFile || disabled}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 dark:disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
src/modules/remote/components/file-transfer/RemoteFilePanel.tsx
Normal file
155
src/modules/remote/components/file-transfer/RemoteFilePanel.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react'
|
||||
import { Folder, FileText, ArrowDown, ChevronRight, ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { fetchRemoteFiles, type RemoteFileItem } from '../../api'
|
||||
|
||||
interface RemoteFilePanelProps {
|
||||
serverHost: string
|
||||
port: number
|
||||
password?: string
|
||||
selectedFile: RemoteFileItem | null
|
||||
onSelect: (file: RemoteFileItem | null) => void
|
||||
onDownload: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const RemoteFilePanel: React.FC<RemoteFilePanelProps> = ({
|
||||
serverHost,
|
||||
port,
|
||||
password,
|
||||
selectedFile,
|
||||
onSelect,
|
||||
onDownload,
|
||||
disabled,
|
||||
}) => {
|
||||
const [currentPath, setCurrentPath] = React.useState('')
|
||||
const [files, setFiles] = React.useState<RemoteFileItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [pathHistory, setPathHistory] = React.useState<string[]>([''])
|
||||
|
||||
const loadFiles = React.useCallback(async (path: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchRemoteFiles(serverHost, port, path, password)
|
||||
setFiles(items)
|
||||
} catch (err) {
|
||||
console.error('Failed to load remote files:', err)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverHost, port, password])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadFiles(currentPath)
|
||||
}, [currentPath, loadFiles])
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (pathHistory.length > 1) {
|
||||
const newHistory = [...pathHistory]
|
||||
newHistory.pop()
|
||||
const prevPath = newHistory[newHistory.length - 1]
|
||||
setPathHistory(newHistory)
|
||||
setCurrentPath(prevPath)
|
||||
onSelect(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoInto = (folder: RemoteFileItem) => {
|
||||
const newPath = currentPath ? `${currentPath}/${folder.name}` : folder.name
|
||||
setPathHistory([...pathHistory, newPath])
|
||||
setCurrentPath(newPath)
|
||||
onSelect(null)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadFiles(currentPath)
|
||||
}
|
||||
|
||||
const getDisplayPath = () => {
|
||||
if (!currentPath) return '远程文件'
|
||||
const parts = currentPath.split('/')
|
||||
return parts[parts.length - 1] || currentPath
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
disabled={pathHistory.length <= 1}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="返回上级"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-[120px]">
|
||||
{getDisplayPath()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw size={14} className={clsx(loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
空文件夹
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => onSelect(file.type === 'dir' ? null : file)}
|
||||
onDoubleClick={() => file.type === 'dir' && handleGoInto(file)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors',
|
||||
selectedFile?.path === file.path
|
||||
? 'bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
||||
)}
|
||||
>
|
||||
{file.type === 'dir' ? (
|
||||
<Folder size={16} className="text-yellow-500 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText size={16} className="text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">
|
||||
{file.name}
|
||||
</span>
|
||||
{selectedFile?.path === file.path && (
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={!selectedFile || disabled}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-300 dark:disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { X, ArrowUp, ArrowDown, CheckCircle, XCircle, Loader } from 'lucide-react'
|
||||
import type { TransferItem } from '../../types'
|
||||
|
||||
interface TransferQueueProps {
|
||||
transfers: TransferItem[]
|
||||
onClear: () => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export const TransferQueue: React.FC<TransferQueueProps> = ({ transfers, onClear, onRemove }) => {
|
||||
if (transfers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
传输队列 ({transfers.length})
|
||||
</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{transfers.map((transfer) => (
|
||||
<div
|
||||
key={transfer.id}
|
||||
className="flex items-center gap-2 text-sm bg-white dark:bg-gray-700 rounded px-2 py-1"
|
||||
>
|
||||
{transfer.type === 'upload' ? (
|
||||
<ArrowUp size={14} className="text-blue-500" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="text-green-500" />
|
||||
)}
|
||||
<span className="flex-1 truncate max-w-[120px]">{transfer.name}</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
transfer.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: transfer.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${transfer.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-12">
|
||||
{transfer.status === 'completed'
|
||||
? '完成'
|
||||
: transfer.status === 'error'
|
||||
? '失败'
|
||||
: `${transfer.progress}%`}
|
||||
</span>
|
||||
{transfer.status === 'transferring' && <Loader size={14} className="animate-spin text-blue-500" />}
|
||||
{transfer.status === 'completed' && <CheckCircle size={14} className="text-green-500" />}
|
||||
{transfer.status === 'error' && <XCircle size={14} className="text-red-500" />}
|
||||
<button
|
||||
onClick={() => onRemove(transfer.id)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/modules/remote/index.tsx
Normal file
11
src/modules/remote/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Monitor } from 'lucide-react'
|
||||
import { RemotePage } from './RemotePage'
|
||||
import { REMOTE_MODULE } from '@shared/modules/remote'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(REMOTE_MODULE, {
|
||||
icon: Monitor,
|
||||
component: RemotePage,
|
||||
})
|
||||
|
||||
export { RemotePage }
|
||||
21
src/modules/remote/types.ts
Normal file
21
src/modules/remote/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type DeviceStatus = 'online' | 'offline'
|
||||
|
||||
export interface DeviceInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: DeviceStatus
|
||||
lastConnected: string
|
||||
screenshotUrl?: string
|
||||
}
|
||||
|
||||
export interface RemoteDevice {
|
||||
id: string
|
||||
deviceName: string
|
||||
serverHost: string
|
||||
desktopPort: number
|
||||
gitPort: number
|
||||
}
|
||||
|
||||
export interface RemoteConfig {
|
||||
devices: RemoteDevice[]
|
||||
}
|
||||
Reference in New Issue
Block a user