Files
ssh-manager/frontend/src/components/SftpFilePickerModal.vue
liumangmang 80fc5c8a0f feat: 增强 Transfers 页面文件浏览功能
- 在 SftpFilePickerModal 中添加搜索功能
- 添加显示/隐藏文件切换按钮(参考 SftpView)
- Remote->Many 模式下目标连接列表自动排除源连接
- 全选功能自动排除源连接
- 添加空状态提示信息
- 优化用户体验和交互逻辑
2026-03-12 17:45:07 +08:00

223 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>