diff --git a/backend/src/main/java/com/sshmanager/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/sshmanager/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..b118c6c --- /dev/null +++ b/backend/src/main/java/com/sshmanager/exception/GlobalExceptionHandler.java @@ -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> handleMaxUploadSize(MaxUploadSizeExceededException e) { + Map err = new HashMap<>(); + err.put("error", "上传失败:文件大小超过限制"); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(err); + } + + @ExceptionHandler(MultipartException.class) + public ResponseEntity> 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 err = new HashMap<>(); + err.put("error", "上传失败:文件大小超过限制"); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(err); + } + + Map err = new HashMap<>(); + err.put("error", "上传失败:表单解析异常"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4ac0336..31178d2 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,30 +1,34 @@ -server: - port: 48080 - -spring: - web: - resources: - add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退 - datasource: - url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1 - driver-class-name: org.h2.Driver - username: sa - password: - h2: - console: - enabled: false - jpa: - hibernate: - ddl-auto: update - show-sql: false - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.H2Dialect - open-in-view: false - -# 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 +server: + port: 48080 + +spring: + web: + resources: + add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退 + servlet: + multipart: + max-file-size: 200MB + max-request-size: 200MB + datasource: + url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: false + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + open-in-view: false + +# 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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 51d19f4..4d65312 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -7,13 +7,26 @@ const client = axios.create({ }, }) -client.interceptors.request.use((config) => { - const token = localStorage.getItem('token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config -}) +client.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + + // 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( (response) => response, diff --git a/frontend/src/api/sftp.ts b/frontend/src/api/sftp.ts index 84f6c24..4232848 100644 --- a/frontend/src/api/sftp.ts +++ b/frontend/src/api/sftp.ts @@ -37,14 +37,13 @@ export async function downloadFile(connectionId: number, path: string) { URL.revokeObjectURL(url) } -export function uploadFile(connectionId: number, path: string, file: File) { - const form = new FormData() - form.append('file', file) - return client.post('/sftp/upload', form, { - params: { connectionId, path }, - headers: { 'Content-Type': 'multipart/form-data' }, - }) -} +export function uploadFile(connectionId: number, path: string, file: File) { + const form = new FormData() + form.append('file', file, file.name) + return client.post('/sftp/upload', form, { + params: { connectionId, path }, + }) +} export function deleteFile(connectionId: number, path: string, directory: boolean) { return client.delete('/sftp/delete', { diff --git a/frontend/src/views/SftpView.vue b/frontend/src/views/SftpView.vue index 22ecb1f..e9df2b8 100644 --- a/frontend/src/views/SftpView.vue +++ b/frontend/src/views/SftpView.vue @@ -4,18 +4,20 @@ 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' +import { + ArrowLeft, + FolderOpen, + File, + Upload, + FolderPlus, + RefreshCw, + Eye, + EyeOff, + Download, + Trash2, + ChevronRight, + Copy, +} from 'lucide-vue-next' const route = useRoute() const router = useRouter() @@ -29,9 +31,18 @@ const pathParts = ref([]) const files = ref([]) const loading = ref(false) const error = ref('') -const uploading = ref(false) -const selectedFile = ref(null) -const fileInputRef = ref(null) +const uploading = ref(false) +const selectedFile = ref(null) +const fileInputRef = ref(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 transferFile = ref(null) @@ -85,38 +96,41 @@ function loadPath() { }) } -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 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) + 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) { - 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 goUp() { + if (loading.value) return + 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) { @@ -139,27 +153,28 @@ 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 { - error.value = '上传失败' - } 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 + 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 = '' + } +} function handleMkdir() { const name = prompt('文件夹名称:') @@ -254,8 +269,8 @@ function formatDate(ts: number): string {
-
-
-
+
+ {{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏隐藏文件)') }} +
+
+ +