feat: 增强 Transfers 页面文件浏览功能
- 在 SftpFilePickerModal 中添加搜索功能 - 添加显示/隐藏文件切换按钮(参考 SftpView) - Remote->Many 模式下目标连接列表自动排除源连接 - 全选功能自动排除源连接 - 添加空状态提示信息 - 优化用户体验和交互逻辑
This commit is contained in:
@@ -4,7 +4,13 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SSH 管理器</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>SSH 传输控制台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -7,6 +7,18 @@ export interface SftpFileInfo {
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface RemoteTransferTask {
|
||||
taskId: string
|
||||
status: 'queued' | 'running' | 'success' | 'error' | 'cancelled'
|
||||
progress: number
|
||||
transferredBytes: number
|
||||
totalBytes: number
|
||||
error?: string
|
||||
createdAt: number
|
||||
startedAt: number
|
||||
finishedAt: number
|
||||
}
|
||||
|
||||
export function listFiles(connectionId: number, path: string) {
|
||||
return client.get<SftpFileInfo[]>('/sftp/list', {
|
||||
params: { connectionId, path: path || '.' },
|
||||
@@ -46,50 +58,64 @@ export function uploadFileWithProgress(connectionId: number, path: string, file:
|
||||
|
||||
xhr.open('POST', url)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
|
||||
// Create a wrapper object to hold the progress callback
|
||||
const wrapper = { onProgress: undefined as ((percent: number) => void) | undefined }
|
||||
|
||||
// Allow caller to attach handlers after this function returns.
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
if ((xhr as any).onProgress) {
|
||||
(xhr as any).onProgress(percent)
|
||||
}
|
||||
console.log('[Upload Progress] event fired:', { lengthComputable: event.lengthComputable, loaded: event.loaded, total: event.total })
|
||||
if (!event.lengthComputable) return
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
console.log('[Upload Progress] percent:', percent, 'hasCallback:', !!wrapper.onProgress)
|
||||
if (wrapper.onProgress) wrapper.onProgress(percent)
|
||||
}
|
||||
|
||||
// Defer send so callers can attach onload/onerror/onProgress safely.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
xhr.send(form)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const responseJson = JSON.parse(xhr.responseText) as { message: string }
|
||||
;(xhr as any).resolve(responseJson)
|
||||
} catch {
|
||||
;(xhr as any).resolve({ message: 'Uploaded' })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const responseJson = JSON.parse(xhr.responseText) as { error?: string }
|
||||
;(xhr as any).reject(new Error(responseJson.error || `Upload failed: ${xhr.status}`))
|
||||
} catch {
|
||||
;(xhr as any).reject(new Error(`Upload failed: ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
;(xhr as any).reject(new Error('Network error'))
|
||||
}
|
||||
|
||||
xhr.send(form)
|
||||
return xhr as XMLHttpRequest & { onProgress?: (percent: number) => void; resolve?: (value: any) => void; reject?: (reason?: any) => void }
|
||||
}, 0)
|
||||
|
||||
// Return XHR with a setter that updates the wrapper
|
||||
const result = xhr as XMLHttpRequest & { onProgress?: (percent: number) => void }
|
||||
Object.defineProperty(result, 'onProgress', {
|
||||
get: () => wrapper.onProgress,
|
||||
set: (fn) => { wrapper.onProgress = fn },
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function uploadFile(connectionId: number, path: string, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
return client.post('/sftp/upload', form, {
|
||||
return client.post<{ taskId: string; message: string }>('/sftp/upload', form, {
|
||||
params: { connectionId, path },
|
||||
})
|
||||
}
|
||||
|
||||
export interface UploadTask {
|
||||
taskId: string
|
||||
status: 'queued' | 'running' | 'success' | 'error'
|
||||
progress: number
|
||||
transferredBytes: number
|
||||
totalBytes: number
|
||||
filename: string
|
||||
error?: string
|
||||
createdAt: number
|
||||
startedAt: number
|
||||
finishedAt: number
|
||||
}
|
||||
|
||||
export function getUploadTask(taskId: string) {
|
||||
return client.get<UploadTask>(`/sftp/upload/tasks/${encodeURIComponent(taskId)}`)
|
||||
}
|
||||
|
||||
export function deleteFile(connectionId: number, path: string, directory: boolean) {
|
||||
return client.delete('/sftp/delete', {
|
||||
params: { connectionId, path, directory },
|
||||
@@ -123,3 +149,78 @@ export function transferRemote(
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createRemoteTransferTask(
|
||||
sourceConnectionId: number,
|
||||
sourcePath: string,
|
||||
targetConnectionId: number,
|
||||
targetPath: string
|
||||
) {
|
||||
return client.post<RemoteTransferTask>('/sftp/transfer-remote/tasks', null, {
|
||||
params: {
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
targetConnectionId,
|
||||
targetPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getRemoteTransferTask(taskId: string) {
|
||||
return client.get<RemoteTransferTask>(`/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}`)
|
||||
}
|
||||
|
||||
export function subscribeRemoteTransferProgress(taskId: string, onProgress: (task: RemoteTransferTask) => void): () => void {
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}/progress`
|
||||
const eventSource = new EventSource(`${url}?token=${encodeURIComponent(token || '')}`)
|
||||
|
||||
eventSource.addEventListener('progress', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('[SSE] Received progress event:', data)
|
||||
onProgress(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE progress data:', e)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
console.error('SSE connection error:', event)
|
||||
eventSource.close()
|
||||
})
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeUploadProgress(taskId: string, onProgress: (task: UploadTask) => void): () => void {
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/sftp/upload/tasks/${encodeURIComponent(taskId)}/progress`
|
||||
const eventSource = new EventSource(`${url}?token=${encodeURIComponent(token || '')}`)
|
||||
|
||||
eventSource.addEventListener('progress', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
onProgress(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE progress data:', e)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
console.error('SSE connection error:', event)
|
||||
eventSource.close()
|
||||
})
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelRemoteTransferTask(taskId: string) {
|
||||
return client.delete<RemoteTransferTask & { cancelRequested: boolean; message?: string }>(
|
||||
`/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}`
|
||||
)
|
||||
}
|
||||
|
||||
222
frontend/src/components/SftpFilePickerModal.vue
Normal file
222
frontend/src/components/SftpFilePickerModal.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import * as sftpApi from '../api/sftp'
|
||||
import type { SftpFileInfo } from '../api/sftp'
|
||||
import { X, FolderOpen, File, ChevronRight, RefreshCw, Eye, EyeOff } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{ open: boolean; connectionId: number | null }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'select', path: string): void
|
||||
}>()
|
||||
|
||||
const currentPath = ref('.')
|
||||
const pathParts = ref<string[]>([])
|
||||
const files = ref<SftpFileInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showHiddenFiles = ref(false)
|
||||
const searchQuery = ref('')
|
||||
let searchDebounceTimer = 0
|
||||
const filteredFiles = ref<SftpFileInfo[]>([])
|
||||
|
||||
const canInteract = computed(() => props.open && props.connectionId != null)
|
||||
|
||||
function applyFileFilters() {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
|
||||
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
|
||||
}
|
||||
|
||||
watch([searchQuery, showHiddenFiles, files], () => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
applyFileFilters()
|
||||
}, 300)
|
||||
}, { immediate: true })
|
||||
|
||||
async function initPath() {
|
||||
if (!canInteract.value || props.connectionId == null) return
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await sftpApi.getPwd(props.connectionId)
|
||||
const p = res.data.path || '/'
|
||||
currentPath.value = p === '/' ? '/' : p
|
||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||
} catch (e: unknown) {
|
||||
currentPath.value = '.'
|
||||
pathParts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!canInteract.value || props.connectionId == null) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await sftpApi.listFiles(props.connectionId, currentPath.value)
|
||||
files.value = res.data
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
error.value = err?.response?.data?.error ?? '获取文件列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToDir(name: string) {
|
||||
if (loading.value) return
|
||||
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
||||
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||
load()
|
||||
}
|
||||
|
||||
function navigateToIndex(i: number) {
|
||||
if (loading.value) return
|
||||
if (i < 0) {
|
||||
currentPath.value = '.'
|
||||
} else {
|
||||
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
||||
}
|
||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||
load()
|
||||
}
|
||||
|
||||
function filePath(file: SftpFileInfo) {
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
return base ? base.replace(/\/$/, '') + '/' + file.name : file.name
|
||||
}
|
||||
|
||||
function handleClick(file: SftpFileInfo) {
|
||||
if (file.directory) {
|
||||
navigateToDir(file.name)
|
||||
return
|
||||
}
|
||||
emit('select', filePath(file))
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.connectionId] as const,
|
||||
async ([open]) => {
|
||||
if (!open) return
|
||||
await initPath()
|
||||
await load()
|
||||
}
|
||||
)
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (!props.open) return
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
clearTimeout(searchDebounceTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-50 bg-black/60 p-4 flex items-center justify-center" role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-3xl rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur shadow-2xl overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-slate-100 font-semibold truncate">选择源文件</h3>
|
||||
<p class="text-xs text-slate-400 truncate">双击文件不需要,单击即选择</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="load"
|
||||
:disabled="loading || !canInteract"
|
||||
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 disabled:opacity-50 cursor-pointer transition-colors"
|
||||
aria-label="刷新"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||
刷新
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="min-h-[44px] w-11 grid place-items-center rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 cursor-pointer transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3 border-b border-slate-700 bg-slate-900/40">
|
||||
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0">
|
||||
<button
|
||||
@click="navigateToIndex(-1)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<template v-for="(part, i) in pathParts" :key="i">
|
||||
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
|
||||
<button
|
||||
@click="navigateToIndex(i)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate max-w-[140px]"
|
||||
>
|
||||
{{ part || '/' }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="flex-1 rounded-lg border border-slate-600 bg-slate-900/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="搜索文件..."
|
||||
aria-label="搜索文件"
|
||||
/>
|
||||
<button
|
||||
@click="showHiddenFiles = !showHiddenFiles"
|
||||
class="min-h-[44px] p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||
>
|
||||
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="mt-2 text-sm text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-auto divide-y divide-slate-800">
|
||||
<button
|
||||
v-for="file in filteredFiles"
|
||||
:key="file.name"
|
||||
@click="handleClick(file)"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-800/40 transition-colors cursor-pointer min-h-[44px]"
|
||||
:aria-label="file.directory ? '打开目录' : '选择文件'"
|
||||
>
|
||||
<component
|
||||
:is="file.directory ? FolderOpen : File"
|
||||
class="w-5 h-5 flex-shrink-0"
|
||||
:class="file.directory ? 'text-cyan-300' : 'text-slate-300'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 min-w-0 truncate text-slate-100">{{ file.name }}</span>
|
||||
<span v-if="!file.directory" class="text-xs text-slate-500">{{ Math.round(file.size / 1024) }} KB</span>
|
||||
</button>
|
||||
|
||||
<div v-if="filteredFiles.length === 0 && !loading" class="px-4 py-10 text-center text-slate-500">
|
||||
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { Server, LogOut, Menu, X } from 'lucide-vue-next'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
@@ -35,21 +35,31 @@ function closeSidebar() {
|
||||
]"
|
||||
>
|
||||
<div class="p-4 border-b border-slate-700">
|
||||
<h1 class="text-lg font-semibold text-slate-100">SSH 管理器</h1>
|
||||
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
|
||||
<RouterLink
|
||||
to="/connections"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
||||
aria-label="连接列表"
|
||||
>
|
||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>连接列表</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
||||
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
|
||||
<RouterLink
|
||||
to="/transfers"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/transfers' }"
|
||||
aria-label="传输"
|
||||
>
|
||||
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>传输</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/connections"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
||||
aria-label="连接列表"
|
||||
>
|
||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>连接列表</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
<button
|
||||
@click="authStore.logout(); $router.push('/login')"
|
||||
|
||||
@@ -14,16 +14,21 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
redirect: '/connections',
|
||||
},
|
||||
{
|
||||
path: 'connections',
|
||||
name: 'Connections',
|
||||
component: () => import('../views/ConnectionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
redirect: '/connections',
|
||||
},
|
||||
{
|
||||
path: 'transfers',
|
||||
name: 'Transfers',
|
||||
component: () => import('../views/TransfersView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'connections',
|
||||
name: 'Connections',
|
||||
component: () => import('../views/ConnectionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'terminal/:id',
|
||||
name: 'Terminal',
|
||||
@@ -45,12 +50,12 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.public) {
|
||||
if (authStore.isAuthenticated && to.path === '/login') {
|
||||
next('/connections')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
if (to.meta.public) {
|
||||
if (authStore.isAuthenticated && to.path === '/login') {
|
||||
next('/connections')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
|
||||
389
frontend/src/stores/transfers.ts
Normal file
389
frontend/src/stores/transfers.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
import { createRemoteTransferTask, subscribeRemoteTransferProgress, uploadFileWithProgress } from '../api/sftp'
|
||||
|
||||
export type TransferMode = 'LOCAL_TO_MANY' | 'REMOTE_TO_MANY'
|
||||
export type TransferItemStatus = 'queued' | 'running' | 'success' | 'error' | 'cancelled'
|
||||
|
||||
export interface TransferItem {
|
||||
id: string
|
||||
label: string
|
||||
status: TransferItemStatus
|
||||
message?: string
|
||||
progress?: number
|
||||
startedAt?: number
|
||||
finishedAt?: number
|
||||
}
|
||||
|
||||
export interface TransferRun {
|
||||
id: string
|
||||
mode: TransferMode
|
||||
title: string
|
||||
createdAt: number
|
||||
items: TransferItem[]
|
||||
status: TransferItemStatus
|
||||
}
|
||||
|
||||
type RunController = {
|
||||
abortAll: () => void
|
||||
unsubscribers: (() => void)[]
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function uid(prefix: string) {
|
||||
return `${prefix}-${now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function startPseudoProgress(item: TransferItem) {
|
||||
const timer = setInterval(() => {
|
||||
if (item.status !== 'running') {
|
||||
clearInterval(timer)
|
||||
return
|
||||
}
|
||||
const current = typeof item.progress === 'number' ? item.progress : 0
|
||||
if (current >= 95) return
|
||||
const step = current < 20 ? 3 : current < 60 ? 2 : 1
|
||||
item.progress = Math.min(95, current + step)
|
||||
}, 500)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
tasks: (() => Promise<T>)[],
|
||||
concurrency: number
|
||||
): Promise<void> {
|
||||
const queue = tasks.slice()
|
||||
const workers: Promise<void>[] = []
|
||||
|
||||
const worker = async () => {
|
||||
while (queue.length) {
|
||||
const task = queue.shift()
|
||||
if (!task) return
|
||||
await task()
|
||||
}
|
||||
}
|
||||
|
||||
const c = Math.max(1, Math.min(concurrency, tasks.length || 1))
|
||||
for (let i = 0; i < c; i++) workers.push(worker())
|
||||
await Promise.allSettled(workers)
|
||||
}
|
||||
|
||||
async function waitForRemoteTransfer(taskId: string, onProgress: (progress: number) => void, unsubscribers: (() => void)[]) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
console.log('[waitForRemoteTransfer] Subscribing to task:', taskId)
|
||||
const unsubscribe = subscribeRemoteTransferProgress(taskId, (task) => {
|
||||
const progress = Math.max(0, Math.min(100, task.progress || 0))
|
||||
console.log('[waitForRemoteTransfer] Progress from SSE:', progress, 'status:', task.status)
|
||||
onProgress(progress)
|
||||
|
||||
if (task.status === 'success') {
|
||||
console.log('[waitForRemoteTransfer] Task succeeded:', taskId)
|
||||
resolve()
|
||||
} else if (task.status === 'error') {
|
||||
console.error('[waitForRemoteTransfer] Task errored:', taskId, task.error)
|
||||
reject(new Error(task.error || 'Transfer failed'))
|
||||
} else if (task.status === 'cancelled') {
|
||||
console.log('[waitForRemoteTransfer] Task cancelled:', taskId)
|
||||
reject(new Error('Cancelled'))
|
||||
}
|
||||
})
|
||||
|
||||
unsubscribers.push(unsubscribe)
|
||||
})
|
||||
}
|
||||
|
||||
function buildRemoteTransferPath(targetDir: string, filename: string) {
|
||||
let targetPath = targetDir.trim()
|
||||
if (!targetPath) targetPath = '/'
|
||||
if (!targetPath.endsWith('/')) targetPath = targetPath + '/'
|
||||
return targetPath + filename
|
||||
}
|
||||
|
||||
export const useTransfersStore = defineStore('transfers', () => {
|
||||
const runs = ref<TransferRun[]>([] as TransferRun[])
|
||||
const controllers = new Map<string, RunController>()
|
||||
|
||||
const recentRuns = computed(() => runs.value.slice(0, 20))
|
||||
|
||||
function clearRuns() {
|
||||
for (const c of controllers.values()) {
|
||||
try {
|
||||
c.abortAll()
|
||||
for (const unsub of c.unsubscribers) {
|
||||
try {
|
||||
unsub()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
controllers.clear()
|
||||
runs.value = []
|
||||
}
|
||||
|
||||
function cancelRun(runId: string) {
|
||||
const runIndex = runs.value.findIndex((r) => r.id === runId)
|
||||
if (runIndex === -1) return
|
||||
const ctrl = controllers.get(runId)
|
||||
if (ctrl) {
|
||||
ctrl.abortAll()
|
||||
for (const unsub of ctrl.unsubscribers) {
|
||||
try {
|
||||
unsub()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
const run = runs.value[runIndex]!
|
||||
run.items.forEach((item) => {
|
||||
if (item.status === 'queued' || item.status === 'running') {
|
||||
item.status = 'cancelled'
|
||||
item.progress = 100
|
||||
item.finishedAt = now()
|
||||
}
|
||||
})
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
|
||||
async function startLocalToMany(params: {
|
||||
files: File[]
|
||||
targetConnectionIds: number[]
|
||||
targetDir: string
|
||||
concurrency?: number
|
||||
}) {
|
||||
const { files, targetConnectionIds, targetDir } = params
|
||||
const concurrency = params.concurrency ?? 3
|
||||
|
||||
const runId = uid('run')
|
||||
const runItems: TransferItem[] = []
|
||||
|
||||
for (const file of files) {
|
||||
for (const connectionId of targetConnectionIds) {
|
||||
runItems.push({
|
||||
id: uid('item'),
|
||||
label: `${file.name} -> #${connectionId}:${targetDir || ''}`,
|
||||
status: 'queued' as const,
|
||||
progress: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const run: TransferRun = {
|
||||
id: runId,
|
||||
mode: 'LOCAL_TO_MANY' as const,
|
||||
title: `Local -> ${targetConnectionIds.length} targets`,
|
||||
createdAt: now(),
|
||||
items: runItems,
|
||||
status: 'queued' as const,
|
||||
}
|
||||
|
||||
runs.value = [run, ...runs.value]
|
||||
|
||||
const activeXhrs: XMLHttpRequest[] = []
|
||||
const unsubscribers: (() => void)[] = []
|
||||
controllers.set(runId, {
|
||||
abortAll: () => {
|
||||
for (const xhr of activeXhrs) {
|
||||
try {
|
||||
xhr.abort()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
unsubscribers,
|
||||
})
|
||||
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
|
||||
for (const file of files) {
|
||||
for (const connectionId of targetConnectionIds) {
|
||||
const itemIndex = runItems.findIndex((i) => i.label.includes(file.name) && i.label.includes(`#${connectionId}`))
|
||||
if (itemIndex === -1) continue
|
||||
const item = runItems[itemIndex]!
|
||||
tasks.push(async () => {
|
||||
if (item.status === 'cancelled') return
|
||||
item.status = 'running'
|
||||
item.progress = 0
|
||||
item.startedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
const stopPseudoProgress = startPseudoProgress(item)
|
||||
|
||||
try {
|
||||
const xhr = uploadFileWithProgress(connectionId, targetDir || '', file)
|
||||
activeXhrs.push(xhr)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let lastTick = 0
|
||||
xhr.onProgress = (percent) => {
|
||||
console.log('[Transfers] onProgress callback fired:', percent, 'item:', item.label)
|
||||
const t = now()
|
||||
if (t - lastTick < 100 && percent !== 100) return
|
||||
lastTick = t
|
||||
const newProgress = Math.max(item.progress || 0, Math.max(0, Math.min(100, percent)))
|
||||
console.log('[Transfers] Updating item.progress from', item.progress, 'to', newProgress)
|
||||
item.progress = newProgress
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
console.log('[Transfers] Set onProgress callback for:', item.label)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
||||
else reject(new Error(xhr.responseText || `HTTP ${xhr.status}`))
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('Network error'))
|
||||
xhr.onabort = () => reject(new Error('Cancelled'))
|
||||
})
|
||||
|
||||
item.status = 'success'
|
||||
item.progress = 100
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as Error)?.message || 'Upload failed'
|
||||
if (msg === 'Cancelled') {
|
||||
item.status = 'cancelled'
|
||||
item.progress = 100
|
||||
} else {
|
||||
item.status = 'error'
|
||||
item.progress = 100
|
||||
item.message = msg
|
||||
}
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
} finally {
|
||||
stopPseudoProgress()
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await runWithConcurrency(tasks, concurrency)
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
|
||||
async function startRemoteToMany(params: {
|
||||
sourceConnectionId: number
|
||||
sourcePath: string
|
||||
targetConnectionIds: number[]
|
||||
targetDirOrPath: string
|
||||
concurrency?: number
|
||||
}) {
|
||||
const { sourceConnectionId, sourcePath, targetConnectionIds, targetDirOrPath } = params
|
||||
const concurrency = params.concurrency ?? 3
|
||||
|
||||
if (sourceConnectionId == null) return
|
||||
|
||||
const runId = uid('run')
|
||||
const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath
|
||||
const runItems: TransferItem[] = targetConnectionIds.map((targetId) => ({
|
||||
id: uid('item'),
|
||||
label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`,
|
||||
status: 'queued' as const,
|
||||
progress: 0,
|
||||
}))
|
||||
|
||||
const run: TransferRun = {
|
||||
id: runId,
|
||||
mode: 'REMOTE_TO_MANY' as const,
|
||||
title: `Remote ${filename} -> ${targetConnectionIds.length} targets`,
|
||||
createdAt: now(),
|
||||
items: runItems,
|
||||
status: 'queued' as const,
|
||||
}
|
||||
|
||||
runs.value = [run, ...runs.value]
|
||||
|
||||
let cancelled = false
|
||||
const unsubscribers: (() => void)[] = []
|
||||
controllers.set(runId, {
|
||||
abortAll: () => {
|
||||
cancelled = true
|
||||
},
|
||||
unsubscribers,
|
||||
})
|
||||
|
||||
const tasks = runItems.map((item, index) => {
|
||||
return async () => {
|
||||
const targetId = targetConnectionIds[index]
|
||||
if (targetId == null) {
|
||||
item.status = 'error'
|
||||
item.progress = 100
|
||||
item.message = 'Missing target connection'
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
return
|
||||
}
|
||||
if (cancelled) {
|
||||
item.status = 'cancelled'
|
||||
item.progress = 100
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
return
|
||||
}
|
||||
item.status = 'running'
|
||||
item.progress = 0
|
||||
item.startedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
console.log('[Remote->Many] Starting transfer:', item.label, 'targetId:', targetId)
|
||||
try {
|
||||
const targetPath = buildRemoteTransferPath(targetDirOrPath, filename)
|
||||
console.log('[Remote->Many] Target path:', targetPath)
|
||||
|
||||
const task = await createRemoteTransferTask(sourceConnectionId, sourcePath, targetId, targetPath)
|
||||
const taskId = task.data.taskId
|
||||
console.log('[Remote->Many] Task created:', taskId)
|
||||
await waitForRemoteTransfer(taskId, (progress) => {
|
||||
console.log('[Remote->Many] Progress update:', progress, 'item:', item.label)
|
||||
item.progress = Math.max(item.progress || 0, progress)
|
||||
runs.value = [...runs.value]
|
||||
}, unsubscribers)
|
||||
|
||||
item.status = 'success'
|
||||
item.progress = 100
|
||||
item.finishedAt = now()
|
||||
console.log('[Remote->Many] Transfer completed:', item.label)
|
||||
runs.value = [...runs.value]
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
const msg = err?.response?.data?.error || (e as Error)?.message || 'Transfer failed'
|
||||
console.error('[Remote->Many] Transfer failed:', item.label, 'error:', msg)
|
||||
if (msg === 'Cancelled') {
|
||||
item.status = 'cancelled'
|
||||
item.progress = 100
|
||||
} else {
|
||||
item.status = 'error'
|
||||
item.progress = 100
|
||||
item.message = msg
|
||||
}
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
} finally {
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await runWithConcurrency(tasks, concurrency)
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
|
||||
return {
|
||||
runs,
|
||||
recentRuns,
|
||||
controllers,
|
||||
clearRuns,
|
||||
cancelRun,
|
||||
startLocalToMany,
|
||||
startRemoteToMany,
|
||||
}
|
||||
})
|
||||
@@ -3,8 +3,28 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--app-bg-0: #0b1220;
|
||||
--app-bg-1: #0a1626;
|
||||
--app-card: rgba(17, 24, 39, 0.72);
|
||||
--app-border: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-900 text-slate-100 antialiased;
|
||||
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
@apply text-slate-100 antialiased;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(34, 211, 238, 0.16), transparent 60%),
|
||||
radial-gradient(900px 500px at 90% 0%, rgba(59, 130, 246, 0.10), transparent 55%),
|
||||
linear-gradient(180deg, var(--app-bg-0), var(--app-bg-1));
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ async function handleSubmit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await authApi.login({ username: username.value, password: password.value })
|
||||
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
||||
router.push('/connections')
|
||||
} catch (e: unknown) {
|
||||
const res = await authApi.login({ username: username.value, password: password.value })
|
||||
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
||||
router.push('/connections')
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
||||
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
||||
} finally {
|
||||
|
||||
@@ -60,11 +60,11 @@ watch([searchQuery, showHiddenFiles, files], () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
stopTransferProgress()
|
||||
})
|
||||
|
||||
const showUploadProgress = ref(false)
|
||||
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
|
||||
const lastUpdate = ref(0)
|
||||
|
||||
const totalProgress = computed(() => {
|
||||
if (uploadProgressList.value.length === 0) return 0
|
||||
@@ -92,11 +92,77 @@ function formatDate(ts: number): string {
|
||||
}
|
||||
|
||||
const showTransferModal = ref(false)
|
||||
const transferFile = ref<SftpFileInfo | null>(null)
|
||||
const transferTargetConnectionId = ref<number | null>(null)
|
||||
const transferTargetPath = ref('')
|
||||
const transferring = ref(false)
|
||||
const transferError = ref('')
|
||||
const transferFile = ref<SftpFileInfo | null>(null)
|
||||
const transferTargetConnectionId = ref<number | null>(null)
|
||||
const transferTargetPath = ref('')
|
||||
const transferring = ref(false)
|
||||
const transferError = ref('')
|
||||
const transferProgress = ref(0)
|
||||
const transferTransferredBytes = ref(0)
|
||||
const transferTotalBytes = ref(0)
|
||||
const transferTaskId = ref('')
|
||||
let transferPollAbort = false
|
||||
|
||||
function resetTransferProgress() {
|
||||
transferProgress.value = 0
|
||||
transferTransferredBytes.value = 0
|
||||
transferTotalBytes.value = 0
|
||||
transferTaskId.value = ''
|
||||
}
|
||||
|
||||
function stopTransferProgress() {
|
||||
transferPollAbort = true
|
||||
}
|
||||
|
||||
function formatTransferBytes(bytes: number) {
|
||||
return formatSize(Math.max(0, bytes || 0))
|
||||
}
|
||||
|
||||
async function waitForTransferTask(taskId: string) {
|
||||
transferPollAbort = false
|
||||
transferTaskId.value = taskId
|
||||
while (!transferPollAbort) {
|
||||
const res = await sftpApi.getRemoteTransferTask(taskId)
|
||||
const task = res.data
|
||||
transferProgress.value = Math.max(0, Math.min(100, task.progress || 0))
|
||||
transferTransferredBytes.value = Math.max(0, task.transferredBytes || 0)
|
||||
transferTotalBytes.value = Math.max(0, task.totalBytes || 0)
|
||||
|
||||
if (task.status === 'success') return task
|
||||
if (task.status === 'error') throw new Error(task.error || '传输失败')
|
||||
if (task.status === 'cancelled') throw new Error('传输已取消')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
|
||||
throw new Error('传输已取消')
|
||||
}
|
||||
|
||||
async function cancelTransfer() {
|
||||
const taskId = transferTaskId.value
|
||||
stopTransferProgress()
|
||||
if (!taskId) {
|
||||
transferring.value = false
|
||||
transferError.value = '传输已取消'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await sftpApi.cancelRemoteTransferTask(taskId)
|
||||
const task = res.data
|
||||
transferProgress.value = Math.max(0, Math.min(100, task.progress || transferProgress.value))
|
||||
transferTransferredBytes.value = Math.max(0, task.transferredBytes || transferTransferredBytes.value)
|
||||
transferTotalBytes.value = Math.max(0, task.totalBytes || transferTotalBytes.value)
|
||||
if (task.cancelRequested) {
|
||||
transferError.value = '已请求取消传输'
|
||||
} else {
|
||||
transferError.value = task.message || '当前传输正在收尾,稍后会结束'
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const res = err as { response?: { data?: { error?: string } } }
|
||||
transferError.value = res?.response?.data?.error ?? '取消传输失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
@@ -208,7 +274,7 @@ async function handleFileSelect(e: Event) {
|
||||
error.value = ''
|
||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||
|
||||
const uploadTasks: { id: string; file: File }[] = []
|
||||
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const file = selected[i]
|
||||
if (!file) continue
|
||||
@@ -227,51 +293,56 @@ async function handleFileSelect(e: Event) {
|
||||
showUploadProgress.value = true
|
||||
|
||||
const MAX_PARALLEL = 5
|
||||
const results: Promise<void>[] = []
|
||||
|
||||
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
||||
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
|
||||
const batchPromises = batch.map(task => {
|
||||
if (!task) return Promise.resolve()
|
||||
const batchPromises = batch.map(async task => {
|
||||
if (!task) return
|
||||
const { id, file } = task
|
||||
const item = uploadProgressList.value.find(item => item.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (!item) return
|
||||
|
||||
item.status = 'uploading'
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onProgress = (percent: number) => {
|
||||
const now = Date.now()
|
||||
if (now - (lastUpdate.value || 0) > 100) {
|
||||
item.uploaded = Math.round((file.size * percent) / 100)
|
||||
item.total = file.size
|
||||
lastUpdate.value = now
|
||||
try {
|
||||
// Start upload and get taskId
|
||||
const uploadRes = await sftpApi.uploadFile(connectionId.value, path, file)
|
||||
const taskId = uploadRes.data.taskId
|
||||
|
||||
// Poll for progress
|
||||
while (true) {
|
||||
const statusRes = await sftpApi.getUploadTask(taskId)
|
||||
const taskStatus = statusRes.data
|
||||
|
||||
item.uploaded = taskStatus.transferredBytes
|
||||
item.total = taskStatus.totalBytes
|
||||
|
||||
if (taskStatus.status === 'success') {
|
||||
item.status = 'success'
|
||||
break
|
||||
}
|
||||
if (taskStatus.status === 'error') {
|
||||
item.status = 'error'
|
||||
item.message = taskStatus.error || 'Upload failed'
|
||||
break
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
}
|
||||
const xhr = sftpApi.uploadFileWithProgress(connectionId.value, path, file)
|
||||
xhr.onProgress = onProgress
|
||||
xhr.onload = () => {
|
||||
item.status = 'success'
|
||||
resolve()
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
item.status = 'error'
|
||||
item.message = 'Network error'
|
||||
reject(new Error('Network error'))
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
item.status = 'error'
|
||||
item.message = err?.response?.data?.error || 'Upload failed'
|
||||
}
|
||||
})
|
||||
results.push(...batchPromises)
|
||||
await Promise.allSettled(batchPromises)
|
||||
}
|
||||
|
||||
await Promise.allSettled(results)
|
||||
await loadPath()
|
||||
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||||
showUploadProgress.value = false
|
||||
uploadProgressList.value = []
|
||||
uploading.value = false
|
||||
fileInputRef.value!.value = ''
|
||||
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||||
toast.success(`成功上传 ${successCount} 个文件`)
|
||||
}
|
||||
|
||||
@@ -310,13 +381,16 @@ async function openTransferModal(file: SftpFileInfo) {
|
||||
showTransferModal.value = true
|
||||
}
|
||||
|
||||
function closeTransferModal() {
|
||||
showTransferModal.value = false
|
||||
transferFile.value = null
|
||||
transferTargetConnectionId.value = null
|
||||
transferTargetPath.value = ''
|
||||
transferError.value = ''
|
||||
}
|
||||
function closeTransferModal() {
|
||||
if (transferring.value) return
|
||||
stopTransferProgress()
|
||||
showTransferModal.value = false
|
||||
transferFile.value = null
|
||||
transferTargetConnectionId.value = null
|
||||
transferTargetPath.value = ''
|
||||
transferError.value = ''
|
||||
resetTransferProgress()
|
||||
}
|
||||
|
||||
async function submitTransfer() {
|
||||
const file = transferFile.value
|
||||
@@ -324,21 +398,25 @@ async function submitTransfer() {
|
||||
if (!file || targetId == null) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
const sourcePath = base ? base + '/' + file.name : file.name
|
||||
let targetPath = transferTargetPath.value.trim()
|
||||
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
|
||||
transferring.value = true
|
||||
transferError.value = ''
|
||||
try {
|
||||
await sftpApi.transferRemote(connectionId.value, sourcePath, targetId, targetPath)
|
||||
loadPath()
|
||||
closeTransferModal()
|
||||
} catch (err: unknown) {
|
||||
const res = err as { response?: { data?: { error?: string } } }
|
||||
transferError.value = res?.response?.data?.error ?? '传输失败'
|
||||
} finally {
|
||||
transferring.value = false
|
||||
}
|
||||
}
|
||||
let targetPath = transferTargetPath.value.trim()
|
||||
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
|
||||
transferring.value = true
|
||||
transferError.value = ''
|
||||
resetTransferProgress()
|
||||
try {
|
||||
const created = await sftpApi.createRemoteTransferTask(connectionId.value, sourcePath, targetId, targetPath)
|
||||
await waitForTransferTask(created.data.taskId)
|
||||
transferProgress.value = 100
|
||||
await loadPath()
|
||||
closeTransferModal()
|
||||
} catch (err: unknown) {
|
||||
const res = err as { response?: { data?: { error?: string } } }
|
||||
transferError.value = res?.response?.data?.error ?? (err as Error)?.message ?? '传输失败'
|
||||
} finally {
|
||||
stopTransferProgress()
|
||||
transferring.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -568,17 +646,32 @@ async function submitTransfer() {
|
||||
placeholder="/"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeTransferModal"
|
||||
:disabled="transferring"
|
||||
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
|
||||
<div v-if="transferring" class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>传输进度</span>
|
||||
<span>{{ transferProgress }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-700">
|
||||
<div
|
||||
class="h-full bg-cyan-500 transition-all duration-300"
|
||||
:style="{ width: transferProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span>{{ formatTransferBytes(transferTransferredBytes) }}</span>
|
||||
<span>{{ transferTotalBytes > 0 ? formatTransferBytes(transferTotalBytes) : '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="transferring ? cancelTransfer() : closeTransferModal()"
|
||||
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 cursor-pointer"
|
||||
>
|
||||
{{ transferring ? '取消传输' : '取消' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="submitTransfer"
|
||||
|
||||
516
frontend/src/views/TransfersView.vue
Normal file
516
frontend/src/views/TransfersView.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useTransfersStore } from '../stores/transfers'
|
||||
import SftpFilePickerModal from '../components/SftpFilePickerModal.vue'
|
||||
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowLeftRight,
|
||||
CloudUpload,
|
||||
FolderOpen,
|
||||
XCircle,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
type Tab = 'local' | 'remote'
|
||||
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const transfersStore = useTransfersStore()
|
||||
|
||||
const tab = ref<Tab>('local')
|
||||
|
||||
// Local -> many
|
||||
const localFiles = ref<File[]>([])
|
||||
const localTargetDir = ref('/')
|
||||
const localSelectedTargets = ref<number[]>([])
|
||||
const localConcurrency = ref(3)
|
||||
|
||||
// Remote -> many
|
||||
const remoteSourceConnectionId = ref<number | null>(null)
|
||||
const remoteSourcePath = ref('')
|
||||
const remoteTargetDirOrPath = ref('/')
|
||||
const remoteSelectedTargets = ref<number[]>([])
|
||||
const remoteConcurrency = ref(3)
|
||||
|
||||
// Picker
|
||||
const pickerOpen = ref(false)
|
||||
|
||||
const connections = computed(() => connectionsStore.connections)
|
||||
const connectionOptions = computed(() => connections.value.slice().sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
||||
// Remote -> Many 模式下的目标连接列表(排除源连接)
|
||||
const remoteTargetConnectionOptions = computed(() =>
|
||||
connectionOptions.value.filter((c) => c.id !== remoteSourceConnectionId.value)
|
||||
)
|
||||
|
||||
const canStartLocal = computed(() => localFiles.value.length > 0 && localSelectedTargets.value.length > 0)
|
||||
const canStartRemote = computed(
|
||||
() => remoteSourceConnectionId.value != null && remoteSourcePath.value.trim() && remoteSelectedTargets.value.length > 0
|
||||
)
|
||||
|
||||
function onLocalFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const list = input.files
|
||||
if (!list) return
|
||||
localFiles.value = Array.from(list)
|
||||
}
|
||||
|
||||
function selectAllLocalTargets() {
|
||||
localSelectedTargets.value = connectionOptions.value.map((c) => c.id)
|
||||
}
|
||||
|
||||
function clearLocalTargets() {
|
||||
localSelectedTargets.value = []
|
||||
}
|
||||
|
||||
function selectAllRemoteTargets() {
|
||||
remoteSelectedTargets.value = remoteTargetConnectionOptions.value.map((c) => c.id)
|
||||
}
|
||||
|
||||
function clearRemoteTargets() {
|
||||
remoteSelectedTargets.value = []
|
||||
}
|
||||
|
||||
function humanRunStatus(status: string) {
|
||||
if (status === 'queued') return 'Queued'
|
||||
if (status === 'running') return 'Running'
|
||||
if (status === 'success') return 'Success'
|
||||
if (status === 'error') return 'Error'
|
||||
if (status === 'cancelled') return 'Cancelled'
|
||||
return status
|
||||
}
|
||||
|
||||
function runBadgeClass(status: string) {
|
||||
if (status === 'success') return 'bg-emerald-500/10 text-emerald-200 border-emerald-500/20'
|
||||
if (status === 'error') return 'bg-red-500/10 text-red-200 border-red-500/20'
|
||||
if (status === 'running') return 'bg-cyan-500/10 text-cyan-200 border-cyan-500/20'
|
||||
if (status === 'cancelled') return 'bg-slate-500/10 text-slate-200 border-slate-500/20'
|
||||
return 'bg-amber-500/10 text-amber-200 border-amber-500/20'
|
||||
}
|
||||
|
||||
function runProgressPercent(run: { items: { status: string; progress?: number }[]; lastUpdate?: number }) {
|
||||
// Access lastUpdate to ensure reactivity
|
||||
void run.lastUpdate
|
||||
const items = run.items
|
||||
if (!items.length) return 0
|
||||
let sum = 0
|
||||
for (const it of items) {
|
||||
if (it.status === 'success' || it.status === 'error' || it.status === 'cancelled') {
|
||||
sum += 1
|
||||
continue
|
||||
}
|
||||
if (it.status === 'running') {
|
||||
const p = typeof it.progress === 'number' ? it.progress : 0
|
||||
sum += Math.max(0, Math.min(1, p / 100))
|
||||
continue
|
||||
}
|
||||
// queued
|
||||
sum += 0
|
||||
}
|
||||
return Math.round((sum / items.length) * 100)
|
||||
}
|
||||
|
||||
async function startLocal() {
|
||||
if (!canStartLocal.value) return
|
||||
await transfersStore.startLocalToMany({
|
||||
files: localFiles.value,
|
||||
targetConnectionIds: localSelectedTargets.value,
|
||||
targetDir: localTargetDir.value,
|
||||
concurrency: localConcurrency.value,
|
||||
})
|
||||
}
|
||||
|
||||
async function startRemote() {
|
||||
if (!canStartRemote.value || remoteSourceConnectionId.value == null) return
|
||||
await transfersStore.startRemoteToMany({
|
||||
sourceConnectionId: remoteSourceConnectionId.value,
|
||||
sourcePath: remoteSourcePath.value.trim(),
|
||||
targetConnectionIds: remoteSelectedTargets.value,
|
||||
targetDirOrPath: remoteTargetDirOrPath.value,
|
||||
concurrency: remoteConcurrency.value,
|
||||
})
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (remoteSourceConnectionId.value == null) return
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
await connectionsStore.fetchConnections().catch(() => {})
|
||||
}
|
||||
if (remoteSourceConnectionId.value == null) {
|
||||
remoteSourceConnectionId.value = connectionOptions.value[0]?.id ?? null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 lg:p-8">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-50">Transfers</h1>
|
||||
<p class="text-sm text-slate-400">本机 -> 多台 / 其他机器 -> 多台</p>
|
||||
</div>
|
||||
<button
|
||||
@click="transfersStore.clearRuns"
|
||||
class="min-h-[44px] inline-flex items-center gap-2 px-3 rounded-lg border border-slate-700 bg-slate-900/40 text-slate-200 hover:bg-slate-800/60 transition-colors cursor-pointer"
|
||||
aria-label="清空队列"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||
清空队列
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
@click="tab = 'local'"
|
||||
class="rounded-2xl border p-4 text-left transition-colors cursor-pointer min-h-[88px]"
|
||||
:class="tab === 'local' ? 'border-cyan-500/40 bg-slate-900/55' : 'border-slate-800 bg-slate-900/35 hover:bg-slate-900/45'"
|
||||
aria-label="切换到本机上传"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 grid place-items-center">
|
||||
<CloudUpload class="w-5 h-5 text-cyan-200" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-slate-100">Local -> Many</p>
|
||||
<p class="text-xs text-slate-400">选择本机文件,分发到多个目标连接</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="tab = 'remote'"
|
||||
class="rounded-2xl border p-4 text-left transition-colors cursor-pointer min-h-[88px]"
|
||||
:class="tab === 'remote' ? 'border-cyan-500/40 bg-slate-900/55' : 'border-slate-800 bg-slate-900/35 hover:bg-slate-900/45'"
|
||||
aria-label="切换到远程转发"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 grid place-items-center">
|
||||
<ArrowLeftRight class="w-5 h-5 text-cyan-200" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-slate-100">Remote -> Many</p>
|
||||
<p class="text-xs text-slate-400">从一台机器取文件,推送到多个目标连接</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[1fr_460px]">
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur p-5">
|
||||
<div v-if="connections.length === 0" class="text-slate-300">
|
||||
<p class="text-sm">暂无连接。请先在 Connections 里添加连接。</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="tab === 'local'" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Local -> Many</h2>
|
||||
<span class="text-xs text-slate-400">并发: {{ localConcurrency }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label class="text-sm text-slate-300">选择本机文件</label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
class="block w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-200 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-800 file:px-3 file:py-2 file:text-slate-200 hover:file:bg-slate-700"
|
||||
@change="onLocalFileChange"
|
||||
aria-label="选择本机文件"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">
|
||||
已选择 {{ localFiles.length }} 个文件
|
||||
<span v-if="localFiles.length">(只支持文件,目录请先打包)</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label for="local-target-dir" class="text-sm text-slate-300">目标目录</label>
|
||||
<input
|
||||
id="local-target-dir"
|
||||
v-model="localTargetDir"
|
||||
type="text"
|
||||
placeholder="/"
|
||||
class="w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-slate-300">目标连接</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="selectAllLocalTargets"
|
||||
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
@click="clearLocalTargets"
|
||||
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="c in connectionOptions"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-950/20 px-3 py-2 min-h-[44px] cursor-pointer hover:bg-slate-950/30"
|
||||
>
|
||||
<input
|
||||
v-model.number="localSelectedTargets"
|
||||
:value="c.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-slate-100 truncate">{{ c.name }}</p>
|
||||
<p class="text-xs text-slate-500 truncate">{{ c.username }}@{{ c.host }}:{{ c.port }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="local-concurrency" class="text-sm text-slate-300">并发</label>
|
||||
<input
|
||||
id="local-concurrency"
|
||||
v-model.number="localConcurrency"
|
||||
type="range"
|
||||
min="1"
|
||||
max="6"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">建议 2-4。并发越高越吃带宽与 CPU。</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="startLocal"
|
||||
:disabled="!canStartLocal"
|
||||
class="min-h-[44px] inline-flex items-center justify-center gap-2 rounded-xl bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="开始上传"
|
||||
>
|
||||
<ArrowUpRight class="w-4 h-4" aria-hidden="true" />
|
||||
开始分发
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Remote -> Many</h2>
|
||||
<span class="text-xs text-slate-400">并发: {{ remoteConcurrency }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label for="remote-source-conn" class="text-sm text-slate-300">源连接</label>
|
||||
<select
|
||||
id="remote-source-conn"
|
||||
v-model.number="remoteSourceConnectionId"
|
||||
class="w-full min-h-[44px] rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
>
|
||||
<option v-for="c in connectionOptions" :key="c.id" :value="c.id">{{ c.name }} ({{ c.username }}@{{ c.host }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label for="remote-source-path" class="text-sm text-slate-300">源文件路径</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="remote-source-path"
|
||||
v-model="remoteSourcePath"
|
||||
type="text"
|
||||
placeholder="/path/to/file"
|
||||
class="flex-1 rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
<button
|
||||
@click="openPicker"
|
||||
:disabled="remoteSourceConnectionId == null"
|
||||
class="min-h-[44px] px-3 rounded-xl border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 disabled:opacity-50 cursor-pointer transition-colors"
|
||||
aria-label="浏览远程文件"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
浏览
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label for="remote-target-dir" class="text-sm text-slate-300">目标目录或路径</label>
|
||||
<input
|
||||
id="remote-target-dir"
|
||||
v-model="remoteTargetDirOrPath"
|
||||
type="text"
|
||||
placeholder="/target/dir/"
|
||||
class="w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">以 / 结尾视为目录,会自动拼接文件名。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-slate-300">目标连接</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="selectAllRemoteTargets"
|
||||
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
@click="clearRemoteTargets"
|
||||
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="c in remoteTargetConnectionOptions"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-950/20 px-3 py-2 min-h-[44px] cursor-pointer hover:bg-slate-950/30"
|
||||
>
|
||||
<input
|
||||
v-model.number="remoteSelectedTargets"
|
||||
:value="c.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-slate-100 truncate">{{ c.name }}</p>
|
||||
<p class="text-xs text-slate-500 truncate">{{ c.username }}@{{ c.host }}:{{ c.port }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="remoteTargetConnectionOptions.length === 0" class="text-xs text-amber-400">
|
||||
没有可用的目标连接(源连接已自动排除)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="remote-concurrency" class="text-sm text-slate-300">并发</label>
|
||||
<input
|
||||
id="remote-concurrency"
|
||||
v-model.number="remoteConcurrency"
|
||||
type="range"
|
||||
min="1"
|
||||
max="6"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">后端是逐个调用 transfer-remote;并发适中即可。</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="startRemote"
|
||||
:disabled="!canStartRemote"
|
||||
class="min-h-[44px] inline-flex items-center justify-center gap-2 rounded-xl bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="开始远程转发"
|
||||
>
|
||||
<ArrowUpRight class="w-4 h-4" aria-hidden="true" />
|
||||
开始转发
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Queue</h2>
|
||||
<span class="text-xs text-slate-500">最近 20 条</span>
|
||||
</div>
|
||||
|
||||
<div v-if="transfersStore.recentRuns.length === 0" class="mt-4 text-sm text-slate-500">
|
||||
暂无任务。创建一个 plan 然后开始。
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="run in transfersStore.recentRuns"
|
||||
:key="run.id"
|
||||
class="rounded-2xl border border-slate-800 bg-slate-950/20 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-slate-100 truncate">{{ run.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500 truncate">{{ new Date(run.createdAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs px-2 py-1 rounded-full border" :class="runBadgeClass(run.status)">
|
||||
{{ humanRunStatus(run.status) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="run.status === 'running' || run.status === 'queued'"
|
||||
@click="transfersStore.cancelRun(run.id)"
|
||||
class="w-10 h-10 grid place-items-center rounded-lg border border-slate-800 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer"
|
||||
aria-label="取消任务"
|
||||
>
|
||||
<XCircle class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{{ run.items.length }} items</span>
|
||||
<span>{{ runProgressPercent(run) }}%</span>
|
||||
</div>
|
||||
<div class="mt-2 w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-cyan-500/80 transition-all duration-200"
|
||||
:style="{ width: runProgressPercent(run) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 max-h-48 overflow-auto space-y-2">
|
||||
<div
|
||||
v-for="item in run.items"
|
||||
:key="item.id"
|
||||
class="flex items-start gap-2 rounded-xl border border-slate-800 bg-slate-950/10 px-3 py-2"
|
||||
>
|
||||
<Loader2 v-if="item.status === 'running'" class="w-4 h-4 mt-0.5 text-cyan-300 animate-spin" aria-hidden="true" />
|
||||
<CheckCircle2 v-else-if="item.status === 'success'" class="w-4 h-4 mt-0.5 text-emerald-300" aria-hidden="true" />
|
||||
<AlertTriangle v-else-if="item.status === 'error'" class="w-4 h-4 mt-0.5 text-red-300" aria-hidden="true" />
|
||||
<XCircle v-else-if="item.status === 'cancelled'" class="w-4 h-4 mt-0.5 text-slate-300" aria-hidden="true" />
|
||||
<span v-else class="w-4 h-4 mt-0.5 rounded-full bg-amber-400/30" aria-hidden="true" />
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-slate-200 truncate">{{ item.label }}</p>
|
||||
<p v-if="item.status === 'running' && item.progress != null" class="mt-1 text-[11px] text-slate-500">
|
||||
{{ item.progress }}%
|
||||
</p>
|
||||
<p v-if="item.status === 'error' && item.message" class="mt-1 text-[11px] text-red-300 break-words">
|
||||
{{ item.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<SftpFilePickerModal
|
||||
:open="pickerOpen"
|
||||
:connection-id="remoteSourceConnectionId"
|
||||
@close="pickerOpen = false"
|
||||
@select="(p) => (remoteSourcePath = p)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user