增强 SSH/SFTP 稳定性并完善安全校验与前端交互
This commit is contained in:
@@ -1,42 +1,87 @@
|
||||
import client from './client'
|
||||
|
||||
export interface SftpFileInfo {
|
||||
name: string
|
||||
directory: boolean
|
||||
size: number
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export function listFiles(connectionId: number, path: string) {
|
||||
return client.get<SftpFileInfo[]>('/sftp/list', {
|
||||
params: { connectionId, path: path || '.' },
|
||||
})
|
||||
}
|
||||
|
||||
export function getPwd(connectionId: number) {
|
||||
return client.get<{ path: string }>('/sftp/pwd', {
|
||||
params: { connectionId },
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadFile(connectionId: number, path: string) {
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams({ connectionId: String(connectionId), path })
|
||||
const res = await fetch(`/api/sftp/download?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = path.split('/').pop() || 'download'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
import client from './client'
|
||||
|
||||
export interface SftpFileInfo {
|
||||
name: string
|
||||
directory: boolean
|
||||
size: number
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export function listFiles(connectionId: number, path: string) {
|
||||
return client.get<SftpFileInfo[]>('/sftp/list', {
|
||||
params: { connectionId, path: path || '.' },
|
||||
})
|
||||
}
|
||||
|
||||
export function getPwd(connectionId: number) {
|
||||
return client.get<{ path: string }>('/sftp/pwd', {
|
||||
params: { connectionId },
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadFile(connectionId: number, path: string) {
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams({ connectionId: String(connectionId), path })
|
||||
const res = await fetch(`/api/sftp/download?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = path.split('/').pop() || 'download'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function uploadFileWithProgress(connectionId: number, path: string, file: File) {
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/sftp/upload?connectionId=${connectionId}&path=${encodeURIComponent(path)}`
|
||||
const xhr = new XMLHttpRequest()
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
|
||||
xhr.open('POST', url)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
export function uploadFile(connectionId: number, path: string, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
@@ -44,37 +89,37 @@ export function uploadFile(connectionId: number, path: string, file: File) {
|
||||
params: { connectionId, path },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteFile(connectionId: number, path: string, directory: boolean) {
|
||||
return client.delete('/sftp/delete', {
|
||||
params: { connectionId, path, directory },
|
||||
})
|
||||
}
|
||||
|
||||
export function createDir(connectionId: number, path: string) {
|
||||
return client.post('/sftp/mkdir', null, {
|
||||
params: { connectionId, path },
|
||||
})
|
||||
}
|
||||
|
||||
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
|
||||
return client.post('/sftp/rename', null, {
|
||||
params: { connectionId, oldPath, newPath },
|
||||
})
|
||||
}
|
||||
|
||||
export function transferRemote(
|
||||
sourceConnectionId: number,
|
||||
sourcePath: string,
|
||||
targetConnectionId: number,
|
||||
targetPath: string
|
||||
) {
|
||||
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
|
||||
params: {
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
targetConnectionId,
|
||||
targetPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteFile(connectionId: number, path: string, directory: boolean) {
|
||||
return client.delete('/sftp/delete', {
|
||||
params: { connectionId, path, directory },
|
||||
})
|
||||
}
|
||||
|
||||
export function createDir(connectionId: number, path: string) {
|
||||
return client.post('/sftp/mkdir', null, {
|
||||
params: { connectionId, path },
|
||||
})
|
||||
}
|
||||
|
||||
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
|
||||
return client.post('/sftp/rename', null, {
|
||||
params: { connectionId, oldPath, newPath },
|
||||
})
|
||||
}
|
||||
|
||||
export function transferRemote(
|
||||
sourceConnectionId: number,
|
||||
sourcePath: string,
|
||||
targetConnectionId: number,
|
||||
targetPath: string
|
||||
) {
|
||||
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
|
||||
params: {
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
targetConnectionId,
|
||||
targetPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,35 +20,59 @@ const username = ref('')
|
||||
const authType = ref<AuthType>('PASSWORD')
|
||||
const password = ref('')
|
||||
const privateKey = ref('')
|
||||
const privateKeyFileName = ref('')
|
||||
const privateKeyInputRef = ref<HTMLInputElement | null>(null)
|
||||
const passphrase = ref('')
|
||||
|
||||
const isEdit = computed(() => !!props.connection)
|
||||
|
||||
const hostError = computed(() => {
|
||||
const h = host.value.trim()
|
||||
if (!h) return ''
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
||||
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
if (ipv4Regex.test(h)) {
|
||||
const parts = h.match(ipv4Regex)
|
||||
if (parts && parts.slice(1, 5).every(p => parseInt(p) <= 255)) return ''
|
||||
return 'IP地址格式无效'
|
||||
}
|
||||
if (hostnameRegex.test(h)) return ''
|
||||
return '主机名格式无效'
|
||||
})
|
||||
|
||||
const portError = computed(() => {
|
||||
const p = port.value
|
||||
if (p < 1 || p > 65535) return '端口号必须在1-65535之间'
|
||||
return ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.connection,
|
||||
(c) => {
|
||||
() => props.connection,
|
||||
(c) => {
|
||||
if (c) {
|
||||
name.value = c.name
|
||||
host.value = c.host
|
||||
port.value = c.port
|
||||
username.value = c.username
|
||||
authType.value = c.authType
|
||||
password.value = ''
|
||||
privateKey.value = ''
|
||||
passphrase.value = ''
|
||||
} else {
|
||||
password.value = ''
|
||||
privateKey.value = ''
|
||||
privateKeyFileName.value = ''
|
||||
passphrase.value = ''
|
||||
} else {
|
||||
name.value = ''
|
||||
host.value = ''
|
||||
port.value = 22
|
||||
username.value = ''
|
||||
authType.value = 'PASSWORD'
|
||||
password.value = ''
|
||||
privateKey.value = ''
|
||||
passphrase.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
password.value = ''
|
||||
privateKey.value = ''
|
||||
privateKeyFileName.value = ''
|
||||
passphrase.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -68,9 +92,53 @@ function handleBackdropMouseUp() {
|
||||
function handleDialogMouseDown() {
|
||||
backdropPressed.value = false
|
||||
}
|
||||
|
||||
function normalizeKeyText(text: string) {
|
||||
return text.replace(/\r\n?/g, '\n').trim() + '\n'
|
||||
}
|
||||
|
||||
async function handlePrivateKeyFileChange(e: Event) {
|
||||
error.value = ''
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Keep it generous; OpenSSH keys are usually a few KB.
|
||||
const MAX_SIZE = 256 * 1024
|
||||
if (file.size > MAX_SIZE) {
|
||||
error.value = '私钥文件过大(>256KB),请检查是否选错文件'
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
privateKey.value = normalizeKeyText(text)
|
||||
privateKeyFileName.value = file.name
|
||||
} catch {
|
||||
error.value = '读取私钥文件失败'
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearPrivateKeyFile() {
|
||||
privateKey.value = ''
|
||||
privateKeyFileName.value = ''
|
||||
if (privateKeyInputRef.value) privateKeyInputRef.value.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = ''
|
||||
const hostErr = hostError.value
|
||||
const portErr = portError.value
|
||||
if (hostErr) {
|
||||
error.value = hostErr
|
||||
return
|
||||
}
|
||||
if (portErr) {
|
||||
error.value = portErr
|
||||
return
|
||||
}
|
||||
if (!name.value.trim()) {
|
||||
error.value = '请填写名称'
|
||||
return
|
||||
@@ -169,6 +237,7 @@ async function handleSubmit() {
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="192.168.1.1"
|
||||
/>
|
||||
<p v-if="hostError" class="mt-1 text-xs text-red-400">{{ hostError }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
|
||||
@@ -180,6 +249,7 @@ async function handleSubmit() {
|
||||
max="65535"
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
<p v-if="portError" class="mt-1 text-xs text-red-400">{{ portError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -217,25 +287,36 @@ async function handleSubmit() {
|
||||
:placeholder="isEdit ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
|
||||
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
|
||||
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
|
||||
</label>
|
||||
<textarea
|
||||
id="privateKey"
|
||||
v-model="privateKey"
|
||||
rows="6"
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
></textarea>
|
||||
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令(可选)</label>
|
||||
<input
|
||||
id="passphrase"
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
|
||||
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
|
||||
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
|
||||
</label>
|
||||
<input
|
||||
ref="privateKeyInputRef"
|
||||
id="privateKey"
|
||||
type="file"
|
||||
accept=".pem,.key,.ppk,.txt,application/x-pem-file"
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500 file:mr-4 file:rounded-md file:border-0 file:bg-slate-600 file:px-3 file:py-2 file:text-slate-100 hover:file:bg-slate-500"
|
||||
@change="handlePrivateKeyFileChange"
|
||||
/>
|
||||
<div v-if="privateKeyFileName" class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-slate-400 truncate">已选择:{{ privateKeyFileName }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-slate-300 hover:text-slate-100 hover:underline"
|
||||
@click="clearPrivateKeyFile"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令(可选)</label>
|
||||
<input
|
||||
id="passphrase"
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Toast from 'vue-toast-notification'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
@@ -7,4 +8,9 @@ import './style.css'
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Toast, {
|
||||
position: 'top-right',
|
||||
duration: 3000,
|
||||
dismissible: true,
|
||||
})
|
||||
app.mount('#app')
|
||||
|
||||
@@ -29,14 +29,14 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Terminal',
|
||||
component: () => import('../views/TerminalView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sftp/:id',
|
||||
name: 'Sftp',
|
||||
component: () => import('../views/SftpView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
path: 'sftp/:id',
|
||||
name: 'Sftp',
|
||||
component: () => import('../views/SftpView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
|
||||
@@ -4,16 +4,16 @@ import { useRouter } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
Lock,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
Lock,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useConnectionsStore()
|
||||
@@ -56,10 +56,10 @@ function openTerminal(conn: Connection) {
|
||||
router.push(`/terminal/${conn.id}`)
|
||||
}
|
||||
|
||||
function openSftp(conn: Connection) {
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
}
|
||||
</script>
|
||||
function openSftp(conn: Connection) {
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
@@ -127,24 +127,24 @@ function openSftp(conn: Connection) {
|
||||
/>
|
||||
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="openTerminal(conn)"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||
>
|
||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||
终端
|
||||
</button>
|
||||
<button
|
||||
@click="openSftp(conn)"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="openTerminal(conn)"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||
>
|
||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||
终端
|
||||
</button>
|
||||
<button
|
||||
@click="openSftp(conn)"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionForm
|
||||
v-if="showForm"
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import * as sftpApi from '../api/sftp'
|
||||
import type { SftpFileInfo } from '../api/sftp'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
File,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Download,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
File,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Download,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const store = useConnectionsStore()
|
||||
|
||||
const connectionId = computed(() => Number(route.params.id))
|
||||
@@ -31,20 +36,62 @@ 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 uploading = ref(false)
|
||||
const selectedFile = ref<string | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const showHiddenFiles = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filteredFiles = computed(() => {
|
||||
let searchDebounceTimer = 0
|
||||
const filteredFiles = ref<SftpFileInfo[]>([])
|
||||
|
||||
function applyFileFilters() {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
|
||||
if (!q) return base
|
||||
return base.filter((f) => f.name.toLowerCase().includes(q))
|
||||
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 })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
})
|
||||
|
||||
const showTransferModal = ref(false)
|
||||
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
|
||||
const totalSize = uploadProgressList.value.reduce((sum, item) => sum + item.size, 0)
|
||||
const uploadedSize = uploadProgressList.value.reduce((sum, item) => {
|
||||
if (item.status === 'success') return sum + item.size
|
||||
if (item.status === 'uploading') return sum + item.uploaded
|
||||
return sum
|
||||
}, 0)
|
||||
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||||
})
|
||||
|
||||
const currentUploadingFile = computed(() => {
|
||||
return uploadProgressList.value.find(item => item.status === 'uploading')?.name || ''
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const showTransferModal = ref(false)
|
||||
const transferFile = ref<SftpFileInfo | null>(null)
|
||||
const transferTargetConnectionId = ref<number | null>(null)
|
||||
const transferTargetPath = ref('')
|
||||
@@ -66,7 +113,7 @@ onMounted(() => {
|
||||
function initPath() {
|
||||
sftpApi.getPwd(connectionId.value).then((res) => {
|
||||
const p = res.data.path || '/'
|
||||
currentPath.value = p || '.'
|
||||
currentPath.value = p === '/' ? '/' : p
|
||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||
loadPath()
|
||||
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
||||
@@ -153,27 +200,79 @@ 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++) {
|
||||
const file = selected[i]
|
||||
if (!file) continue
|
||||
await sftpApi.uploadFile(connectionId.value, path, file)
|
||||
}
|
||||
loadPath()
|
||||
} catch (err: unknown) {
|
||||
const res = err as { response?: { data?: { error?: string } } }
|
||||
error.value = res?.response?.data?.error ?? '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
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
|
||||
|
||||
const uploadTasks: { id: string; file: File }[] = []
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const file = selected[i]
|
||||
if (!file) continue
|
||||
uploadTasks.push({ id: `${Date.now()}-${i}`, file })
|
||||
}
|
||||
|
||||
uploadProgressList.value = uploadTasks.map(({ id, file }) => ({
|
||||
id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploaded: 0,
|
||||
total: file.size,
|
||||
status: 'pending',
|
||||
}))
|
||||
|
||||
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 { id, file } = task
|
||||
const item = uploadProgressList.value.find(item => item.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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'))
|
||||
}
|
||||
})
|
||||
})
|
||||
results.push(...batchPromises)
|
||||
await Promise.allSettled(batchPromises)
|
||||
}
|
||||
|
||||
await Promise.allSettled(results)
|
||||
await loadPath()
|
||||
showUploadProgress.value = false
|
||||
uploadProgressList.value = []
|
||||
uploading.value = false
|
||||
fileInputRef.value!.value = ''
|
||||
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||||
toast.success(`成功上传 ${successCount} 个文件`)
|
||||
}
|
||||
|
||||
function handleMkdir() {
|
||||
@@ -240,16 +339,6 @@ async function submitTransfer() {
|
||||
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>
|
||||
@@ -338,6 +427,41 @@ function formatDate(ts: number): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300">上传进度: {{ totalProgress }}%</span>
|
||||
<span class="text-sm text-slate-400">{{ currentUploadingFile || '准备上传...' }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-700 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-cyan-600 h-full transition-all duration-300"
|
||||
:style="{ width: totalProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 max-h-40 overflow-y-auto">
|
||||
<div
|
||||
v-for="item in uploadProgressList"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<CheckCircle v-if="item.status === 'success'" class="w-4 h-4 flex-shrink-0 text-green-500" aria-hidden="true" />
|
||||
<AlertCircle v-else-if="item.status === 'error'" class="w-4 h-4 flex-shrink-0 text-red-500" aria-hidden="true" />
|
||||
<Loader v-else-if="item.status === 'uploading'" class="w-4 h-4 flex-shrink-0 text-cyan-500 animate-spin" aria-hidden="true" />
|
||||
<File v-else class="w-4 h-4 flex-shrink-0 text-slate-500" aria-hidden="true" />
|
||||
<span class="flex-1 truncate text-slate-300">{{ item.name }}</span>
|
||||
<span class="text-slate-400 text-xs">
|
||||
{{ formatSize(item.size) }}
|
||||
<template v-if="item.status === 'uploading'">
|
||||
({{ Math.round((item.uploaded / item.total) * 100) }}%)
|
||||
</template>
|
||||
<template v-else-if="item.status === 'success'">
|
||||
✓
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -397,7 +521,7 @@ function formatDate(ts: number): string {
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||||
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏隐藏文件)') }}
|
||||
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user