- 在 SftpFilePickerModal 中添加搜索功能 - 添加显示/隐藏文件切换按钮(参考 SftpView) - Remote->Many 模式下目标连接列表自动排除源连接 - 全选功能自动排除源连接 - 添加空状态提示信息 - 优化用户体验和交互逻辑
223 lines
8.4 KiB
Vue
223 lines
8.4 KiB
Vue
<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>
|