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:
liumangmang
2026-03-10 18:07:51 +08:00
parent 939b2ff287
commit 8845847ce2
5 changed files with 223 additions and 133 deletions

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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', {

View File

@@ -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