Initial commit: SSH Manager (backend + frontend)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
436
frontend/src/views/SftpView.vue
Normal file
436
frontend/src/views/SftpView.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import * as sftpApi from '../api/sftp'
|
||||
import type { SftpFileInfo } from '../api/sftp'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
File,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useConnectionsStore()
|
||||
|
||||
const connectionId = computed(() => Number(route.params.id))
|
||||
const conn = ref(store.getConnection(connectionId.value))
|
||||
|
||||
const currentPath = ref('.')
|
||||
const pathParts = ref<string[]>([])
|
||||
const files = ref<SftpFileInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const uploading = ref(false)
|
||||
const selectedFile = ref<string | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
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('')
|
||||
|
||||
onMounted(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
if (!conn.value) {
|
||||
store.fetchConnections().then(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
initPath()
|
||||
})
|
||||
} else {
|
||||
initPath()
|
||||
}
|
||||
})
|
||||
|
||||
function initPath() {
|
||||
sftpApi.getPwd(connectionId.value).then((res) => {
|
||||
const p = res.data.path || '/'
|
||||
currentPath.value = p || '.'
|
||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||
loadPath()
|
||||
}).catch(() => {
|
||||
currentPath.value = '.'
|
||||
pathParts.value = []
|
||||
loadPath()
|
||||
})
|
||||
}
|
||||
|
||||
function loadPath() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
sftpApi
|
||||
.listFiles(connectionId.value, currentPath.value)
|
||||
.then((res) => {
|
||||
files.value = res.data.sort((a, b) => {
|
||||
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
error.value = '获取文件列表失败'
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function navigateToDir(name: string) {
|
||||
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)
|
||||
loadPath()
|
||||
}
|
||||
|
||||
function navigateToIndex(i: number) {
|
||||
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)
|
||||
loadPath()
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
||||
return
|
||||
}
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
if (parts.length <= 1) {
|
||||
currentPath.value = '/'
|
||||
pathParts.value = ['']
|
||||
} else {
|
||||
parts.pop()
|
||||
currentPath.value = '/' + parts.join('/')
|
||||
pathParts.value = parts
|
||||
}
|
||||
loadPath()
|
||||
}
|
||||
|
||||
function handleFileClick(file: SftpFileInfo) {
|
||||
if (file.directory) {
|
||||
navigateToDir(file.name)
|
||||
} else {
|
||||
selectedFile.value = file.name
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(file: SftpFileInfo) {
|
||||
if (file.directory) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
const path = base ? base + '/' + file.name : file.name
|
||||
sftpApi.downloadFile(connectionId.value, path).catch(() => {
|
||||
error.value = '下载失败'
|
||||
})
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const selected = input.files
|
||||
if (!selected?.length) return
|
||||
uploading.value = true
|
||||
error.value = ''
|
||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||
try {
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
await sftpApi.uploadFile(connectionId.value, path, selected[i])
|
||||
}
|
||||
loadPath()
|
||||
} catch {
|
||||
error.value = '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleMkdir() {
|
||||
const name = prompt('文件夹名称:')
|
||||
if (!name?.trim()) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
const path = base ? base + '/' + name : name
|
||||
sftpApi.createDir(connectionId.value, path).then(() => loadPath()).catch(() => {
|
||||
error.value = '创建文件夹失败'
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete(file: SftpFileInfo) {
|
||||
if (!confirm(`确定删除「${file.name}」?`)) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
const path = base ? base + '/' + file.name : file.name
|
||||
sftpApi.deleteFile(connectionId.value, path, file.directory).then(() => loadPath()).catch(() => {
|
||||
error.value = '删除失败'
|
||||
})
|
||||
}
|
||||
|
||||
const targetConnectionOptions = computed(() => {
|
||||
const list = store.connections.filter((c) => c.id !== connectionId.value)
|
||||
return list
|
||||
})
|
||||
|
||||
async function openTransferModal(file: SftpFileInfo) {
|
||||
if (file.directory) return
|
||||
if (store.connections.length === 0) await store.fetchConnections()
|
||||
transferFile.value = file
|
||||
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
|
||||
transferTargetPath.value = currentPath.value === '.' || !currentPath.value ? '/' : currentPath.value
|
||||
if (!transferTargetPath.value.endsWith('/')) transferTargetPath.value += '/'
|
||||
transferError.value = ''
|
||||
showTransferModal.value = true
|
||||
}
|
||||
|
||||
function closeTransferModal() {
|
||||
showTransferModal.value = false
|
||||
transferFile.value = null
|
||||
transferTargetConnectionId.value = null
|
||||
transferTargetPath.value = ''
|
||||
transferError.value = ''
|
||||
}
|
||||
|
||||
async function submitTransfer() {
|
||||
const file = transferFile.value
|
||||
const targetId = transferTargetConnectionId.value
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<button
|
||||
@click="router.push('/connections')"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold text-slate-100">
|
||||
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
|
||||
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 flex-1">
|
||||
<button
|
||||
@click="navigateToIndex(-1)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 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-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
|
||||
>
|
||||
{{ part || '/' }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
@click="triggerUpload"
|
||||
:disabled="uploading"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||
aria-label="上传"
|
||||
>
|
||||
<Upload class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleMkdir"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="新建文件夹"
|
||||
>
|
||||
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="loadPath"
|
||||
:disabled="loading"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||
aria-label="刷新"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<div v-if="loading" class="p-8 text-center text-slate-400">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-slate-700">
|
||||
<button
|
||||
v-if="currentPath !== '.' && pathParts.length > 1"
|
||||
@click="goUp"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
|
||||
>
|
||||
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
||||
<span class="text-slate-400">..</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="file in files"
|
||||
:key="file.name"
|
||||
@click="handleFileClick(file)"
|
||||
@dblclick="file.directory ? navigateToDir(file.name) : handleDownload(file)"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
|
||||
>
|
||||
<component
|
||||
:is="file.directory ? FolderOpen : File"
|
||||
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
|
||||
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
|
||||
{{ formatSize(file.size) }}
|
||||
</span>
|
||||
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
v-if="!file.directory"
|
||||
@click.stop="handleDownload(file)"
|
||||
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="下载"
|
||||
>
|
||||
<Download class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!file.directory"
|
||||
@click.stop="openTransferModal(file)"
|
||||
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="复制到远程"
|
||||
>
|
||||
<Copy class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click.stop="handleDelete(file)"
|
||||
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||||
空目录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTransferModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="transfer-modal-title"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
|
||||
<h3 id="transfer-modal-title" class="text-lg font-semibold text-slate-100 mb-4">
|
||||
复制到远程
|
||||
</h3>
|
||||
<p v-if="transferFile" class="text-sm text-slate-400 mb-3">
|
||||
文件:{{ transferFile.name }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="transfer-target-conn" class="block text-sm text-slate-400 mb-1">目标连接</label>
|
||||
<select
|
||||
id="transfer-target-conn"
|
||||
v-model.number="transferTargetConnectionId"
|
||||
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
>
|
||||
<option v-for="c in targetConnectionOptions" :key="c.id" :value="c.id">
|
||||
{{ c.name }} ({{ c.username }}@{{ c.host }})
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="targetConnectionOptions.length === 0" class="mt-1 text-xs text-amber-400">
|
||||
暂无其他连接,请先在连接管理中添加
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="transfer-target-path" class="block text-sm text-slate-400 mb-1">目标路径(目录以 / 结尾)</label>
|
||||
<input
|
||||
id="transfer-target-path"
|
||||
v-model="transferTargetPath"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
@click="submitTransfer"
|
||||
:disabled="transferring || transferTargetConnectionId == null"
|
||||
class="rounded-lg bg-cyan-600 px-4 py-2 text-white hover:bg-cyan-500 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{{ transferring ? '传输中...' : '确定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user