feat: 增强 Transfers 页面文件浏览功能

- 在 SftpFilePickerModal 中添加搜索功能
- 添加显示/隐藏文件切换按钮(参考 SftpView)
- Remote->Many 模式下目标连接列表自动排除源连接
- 全选功能自动排除源连接
- 添加空状态提示信息
- 优化用户体验和交互逻辑
This commit is contained in:
liumangmang
2026-03-12 17:45:07 +08:00
parent 085123697e
commit 80fc5c8a0f
18 changed files with 2298 additions and 294 deletions

View 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>