Improve SFTP file view and upload handling
Add hidden-file toggle and search, prevent rapid-click path duplication, fix multipart upload headers, and raise backend upload size limits with clearer errors.
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
import org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
import org.springframework.web.multipart.MultipartException;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleMaxUploadSize(MaxUploadSizeExceededException e) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "上传失败:文件大小超过限制");
|
||||||
|
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MultipartException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleMultipart(MultipartException e) {
|
||||||
|
Throwable root = e;
|
||||||
|
while (root.getCause() != null && root.getCause() != root) {
|
||||||
|
root = root.getCause();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root instanceof FileSizeLimitExceededException
|
||||||
|
|| (root.getMessage() != null && root.getMessage().contains("exceeds its maximum permitted size"))) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "上传失败:文件大小超过限制");
|
||||||
|
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "上传失败:表单解析异常");
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,34 @@
|
|||||||
server:
|
server:
|
||||||
port: 48080
|
port: 48080
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
||||||
datasource:
|
servlet:
|
||||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
multipart:
|
||||||
driver-class-name: org.h2.Driver
|
max-file-size: 200MB
|
||||||
username: sa
|
max-request-size: 200MB
|
||||||
password:
|
datasource:
|
||||||
h2:
|
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||||
console:
|
driver-class-name: org.h2.Driver
|
||||||
enabled: false
|
username: sa
|
||||||
jpa:
|
password:
|
||||||
hibernate:
|
h2:
|
||||||
ddl-auto: update
|
console:
|
||||||
show-sql: false
|
enabled: false
|
||||||
properties:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
ddl-auto: update
|
||||||
dialect: org.hibernate.dialect.H2Dialect
|
show-sql: false
|
||||||
open-in-view: false
|
properties:
|
||||||
|
hibernate:
|
||||||
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
|
format_sql: true
|
||||||
sshmanager:
|
dialect: org.hibernate.dialect.H2Dialect
|
||||||
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=}
|
open-in-view: false
|
||||||
jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production}
|
|
||||||
jwt-expiration-ms: 86400000
|
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
|
||||||
|
sshmanager:
|
||||||
|
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=}
|
||||||
|
jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production}
|
||||||
|
jwt-expiration-ms: 86400000
|
||||||
|
|||||||
@@ -7,13 +7,26 @@ const client = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
client.interceptors.request.use((config) => {
|
client.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
|
||||||
})
|
// Let the browser set the correct multipart boundary.
|
||||||
|
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||||
|
const headers: any = config.headers ?? {}
|
||||||
|
if (typeof headers.set === 'function') {
|
||||||
|
headers.set('Content-Type', undefined)
|
||||||
|
} else {
|
||||||
|
delete headers['Content-Type']
|
||||||
|
delete headers['content-type']
|
||||||
|
}
|
||||||
|
config.headers = headers
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
|
|||||||
@@ -37,14 +37,13 @@ export async function downloadFile(connectionId: number, path: string) {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadFile(connectionId: number, path: string, file: File) {
|
export function uploadFile(connectionId: number, path: string, file: File) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file, file.name)
|
||||||
return client.post('/sftp/upload', form, {
|
return client.post('/sftp/upload', form, {
|
||||||
params: { connectionId, path },
|
params: { connectionId, path },
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteFile(connectionId: number, path: string, directory: boolean) {
|
export function deleteFile(connectionId: number, path: string, directory: boolean) {
|
||||||
return client.delete('/sftp/delete', {
|
return client.delete('/sftp/delete', {
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import * as sftpApi from '../api/sftp'
|
import * as sftpApi from '../api/sftp'
|
||||||
import type { SftpFileInfo } from '../api/sftp'
|
import type { SftpFileInfo } from '../api/sftp'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
File,
|
File,
|
||||||
Upload,
|
Upload,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Download,
|
Eye,
|
||||||
Trash2,
|
EyeOff,
|
||||||
ChevronRight,
|
Download,
|
||||||
Copy,
|
Trash2,
|
||||||
} from 'lucide-vue-next'
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -29,9 +31,18 @@ const pathParts = ref<string[]>([])
|
|||||||
const files = ref<SftpFileInfo[]>([])
|
const files = ref<SftpFileInfo[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const selectedFile = ref<string | null>(null)
|
const selectedFile = ref<string | null>(null)
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const showHiddenFiles = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filteredFiles = computed(() => {
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
|
||||||
const showTransferModal = ref(false)
|
const showTransferModal = ref(false)
|
||||||
const transferFile = ref<SftpFileInfo | null>(null)
|
const transferFile = ref<SftpFileInfo | null>(null)
|
||||||
@@ -85,38 +96,41 @@ function loadPath() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToDir(name: string) {
|
function navigateToDir(name: string) {
|
||||||
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
if (loading.value) return
|
||||||
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
||||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
||||||
loadPath()
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
}
|
loadPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
loadPath()
|
||||||
|
}
|
||||||
|
|
||||||
function navigateToIndex(i: number) {
|
function goUp() {
|
||||||
if (i < 0) {
|
if (loading.value) return
|
||||||
currentPath.value = '.'
|
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
||||||
} else {
|
return
|
||||||
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
}
|
||||||
}
|
const parts = currentPath.value.split('/').filter(Boolean)
|
||||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
if (parts.length <= 1) {
|
||||||
loadPath()
|
currentPath.value = '/'
|
||||||
}
|
pathParts.value = ['']
|
||||||
|
} else {
|
||||||
function goUp() {
|
parts.pop()
|
||||||
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
currentPath.value = '/' + parts.join('/')
|
||||||
return
|
pathParts.value = parts
|
||||||
}
|
}
|
||||||
const parts = currentPath.value.split('/').filter(Boolean)
|
loadPath()
|
||||||
if (parts.length <= 1) {
|
}
|
||||||
currentPath.value = '/'
|
|
||||||
pathParts.value = ['']
|
|
||||||
} else {
|
|
||||||
parts.pop()
|
|
||||||
currentPath.value = '/' + parts.join('/')
|
|
||||||
pathParts.value = parts
|
|
||||||
}
|
|
||||||
loadPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileClick(file: SftpFileInfo) {
|
function handleFileClick(file: SftpFileInfo) {
|
||||||
if (file.directory) {
|
if (file.directory) {
|
||||||
@@ -139,27 +153,28 @@ function triggerUpload() {
|
|||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileSelect(e: Event) {
|
async function handleFileSelect(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
const selected = input.files
|
const selected = input.files
|
||||||
if (!selected?.length) return
|
if (!selected?.length) return
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < selected.length; i++) {
|
for (let i = 0; i < selected.length; i++) {
|
||||||
const file = selected[i]
|
const file = selected[i]
|
||||||
if (!file) continue
|
if (!file) continue
|
||||||
await sftpApi.uploadFile(connectionId.value, path, file)
|
await sftpApi.uploadFile(connectionId.value, path, file)
|
||||||
}
|
}
|
||||||
loadPath()
|
loadPath()
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
error.value = '上传失败'
|
const res = err as { response?: { data?: { error?: string } } }
|
||||||
} finally {
|
error.value = res?.response?.data?.error ?? '上传失败'
|
||||||
uploading.value = false
|
} finally {
|
||||||
input.value = ''
|
uploading.value = false
|
||||||
}
|
input.value = ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleMkdir() {
|
function handleMkdir() {
|
||||||
const name = prompt('文件夹名称:')
|
const name = prompt('文件夹名称:')
|
||||||
@@ -254,8 +269,8 @@ function formatDate(ts: number): string {
|
|||||||
|
|
||||||
<div class="flex-1 overflow-auto p-4">
|
<div class="flex-1 overflow-auto p-4">
|
||||||
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
<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">
|
<div class="flex flex-col sm:flex-row sm: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">
|
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full sm:flex-1">
|
||||||
<button
|
<button
|
||||||
@click="navigateToIndex(-1)"
|
@click="navigateToIndex(-1)"
|
||||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
||||||
@@ -272,12 +287,29 @@ function formatDate(ts: number): string {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="flex items-center gap-1 flex-shrink-0">
|
<div class="w-full sm:w-auto flex items-center gap-2 justify-end">
|
||||||
<button
|
<div class="flex-1 sm:flex-none">
|
||||||
@click="triggerUpload"
|
<input
|
||||||
:disabled="uploading"
|
v-model="searchQuery"
|
||||||
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"
|
type="text"
|
||||||
aria-label="上传"
|
class="w-full sm:w-56 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="搜索文件"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showHiddenFiles = !showHiddenFiles"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||||
|
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||||
|
>
|
||||||
|
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<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" />
|
<Upload class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -321,13 +353,13 @@ function formatDate(ts: number): string {
|
|||||||
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
||||||
<span class="text-slate-400">..</span>
|
<span class="text-slate-400">..</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="file in files"
|
v-for="file in filteredFiles"
|
||||||
:key="file.name"
|
:key="file.name"
|
||||||
@click="handleFileClick(file)"
|
@click="handleFileClick(file)"
|
||||||
@dblclick="file.directory ? navigateToDir(file.name) : handleDownload(file)"
|
@dblclick="!file.directory && 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"
|
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
|
<component
|
||||||
:is="file.directory ? FolderOpen : File"
|
:is="file.directory ? FolderOpen : File"
|
||||||
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
||||||
@@ -364,12 +396,12 @@ function formatDate(ts: number): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||||||
空目录
|
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏隐藏文件)') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user