diff --git a/backend/src/main/java/com/sshmanager/controller/SftpController.java b/backend/src/main/java/com/sshmanager/controller/SftpController.java index 7b21da6..9d70778 100644 --- a/backend/src/main/java/com/sshmanager/controller/SftpController.java +++ b/backend/src/main/java/com/sshmanager/controller/SftpController.java @@ -40,6 +40,7 @@ import java.util.stream.Stream; public class SftpController { private static final Logger log = LoggerFactory.getLogger(SftpController.class); + private static final String UPLOAD_CONFLICT_CODE = "SFTP_UPLOAD_CONFLICT"; private final ConnectionService connectionService; private final UserRepository userRepository; @@ -292,11 +293,32 @@ public class SftpController { public ResponseEntity> upload( @RequestParam Long connectionId, @RequestParam String path, + @RequestParam(defaultValue = "false") boolean overwrite, @RequestParam("file") MultipartFile file, Authentication authentication) { java.io.File tempFile = null; try { Long userId = getCurrentUserId(authentication); + String key = sessionKey(userId, connectionId); + String filename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "upload.bin"; + String remotePath = resolveUploadPath(path, filename); + + UploadConflictInfo initialConflict = withSessionLock(key, () -> { + try { + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + return detectUploadConflict(session, remotePath, filename, overwrite); + } catch (Exception e) { + SftpService.SftpSession existing = sessions.remove(key); + if (existing != null) { + existing.disconnect(); + } + throw new RuntimeException(e); + } + }); + if (initialConflict != null) { + return buildUploadConflictResponse(initialConflict); + } + String taskId = UUID.randomUUID().toString(); String taskKey = uploadTaskKey(userId, taskId); @@ -305,25 +327,25 @@ public class SftpController { if (!uploadTempDir.exists() && !uploadTempDir.mkdirs()) { throw new IOException("Failed to create upload temp directory: " + uploadTempDir.getAbsolutePath()); } - tempFile = new java.io.File(uploadTempDir, taskId + "_" + file.getOriginalFilename()); + tempFile = new java.io.File(uploadTempDir, taskId + "_" + filename); file.transferTo(tempFile); final java.io.File savedFile = tempFile; UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId, - path, file.getOriginalFilename(), file.getSize()); + path, filename, file.getSize()); status.setController(this); uploadTasks.put(taskKey, status); Future future = transferTaskExecutor.submit(() -> { status.setStatus("running"); - String key = sessionKey(userId, connectionId); try { withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - String remotePath = (path == null || path.isEmpty() || path.equals("/")) - ? "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) - : (path.endsWith("/") ? path + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) : path + "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1)); + UploadConflictInfo conflict = detectUploadConflict(session, remotePath, filename, overwrite); + if (conflict != null) { + throw new IllegalStateException(conflict.message); + } AtomicLong transferred = new AtomicLong(0); try (java.io.InputStream in = new java.io.FileInputStream(savedFile)) { @@ -383,6 +405,42 @@ public class SftpController { } } + private String resolveUploadPath(String path, String filename) { + if (path == null || path.isEmpty() || "/".equals(path)) { + return "/" + filename; + } + return path.endsWith("/") ? path + filename : path + "/" + filename; + } + + private UploadConflictInfo detectUploadConflict(SftpService.SftpSession session, + String remotePath, + String filename, + boolean overwrite) throws Exception { + SftpService.PathInfo existing = sftpService.statIfExists(session, remotePath); + if (existing == null) { + return null; + } + if (overwrite && !existing.directory) { + return null; + } + + boolean canOverwrite = !existing.directory; + String message = existing.directory + ? "目标目录中已存在同名文件夹,无法覆盖。" + : "目标目录中已存在同名文件。"; + return new UploadConflictInfo(filename, existing.directory ? "dir" : "file", canOverwrite, message); + } + + private ResponseEntity> buildUploadConflictResponse(UploadConflictInfo conflict) { + Map response = new HashMap<>(); + response.put("code", UPLOAD_CONFLICT_CODE); + response.put("fileName", conflict.fileName); + response.put("conflictType", conflict.conflictType); + response.put("canOverwrite", conflict.canOverwrite); + response.put("message", conflict.message); + return ResponseEntity.status(409).body(response); + } + @DeleteMapping("/delete") public ResponseEntity> delete( @RequestParam Long connectionId, @@ -739,6 +797,20 @@ public class SftpController { private final SftpSessionExpiryCleanup cleanupTask = new SftpSessionExpiryCleanup(); + private static class UploadConflictInfo { + private final String fileName; + private final String conflictType; + private final boolean canOverwrite; + private final String message; + + private UploadConflictInfo(String fileName, String conflictType, boolean canOverwrite, String message) { + this.fileName = fileName; + this.conflictType = conflictType; + this.canOverwrite = canOverwrite; + this.message = message; + } + } + public static class SftpSessionExpiryCleanup { private final Map lastAccessTime = new ConcurrentHashMap<>(); diff --git a/backend/src/main/java/com/sshmanager/service/SftpService.java b/backend/src/main/java/com/sshmanager/service/SftpService.java index cb2e3b5..62deaf7 100644 --- a/backend/src/main/java/com/sshmanager/service/SftpService.java +++ b/backend/src/main/java/com/sshmanager/service/SftpService.java @@ -91,10 +91,10 @@ public class SftpService { } public static class FileInfo { - public String name; - public boolean directory; - public long size; - public long mtime; + public String name; + public boolean directory; + public long size; + public long mtime; public FileInfo(String name, boolean directory, long size, long mtime) { this.name = name; @@ -104,16 +104,24 @@ public class SftpService { } } + public static class PathInfo { + public final boolean directory; + + public PathInfo(boolean directory) { + this.directory = directory; + } + } + public interface TransferProgressListener { void onStart(long totalBytes); void onProgress(long transferredBytes, long totalBytes); } - public List listFiles(SftpSession sftpSession, String path) throws Exception { - String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim(); - try { - Vector entries = sftpSession.getChannel().ls(listPath); + public List listFiles(SftpSession sftpSession, String path) throws Exception { + String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim(); + try { + Vector entries = sftpSession.getChannel().ls(listPath); List result = new ArrayList<>(); for (Object obj : entries) { ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj; @@ -297,4 +305,16 @@ public class SftpService { } } } + + public PathInfo statIfExists(SftpSession sftpSession, String path) throws Exception { + try { + SftpATTRS attrs = sftpSession.getChannel().stat(path); + return new PathInfo(attrs.isDir()); + } catch (SftpException e) { + if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) { + return null; + } + throw e; + } + } } diff --git a/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java b/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java index 374e461..a4d6c88 100644 --- a/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java +++ b/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java @@ -10,15 +10,24 @@ import com.sshmanager.service.SftpService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.mock.web.MockMultipartFile; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.io.TempDir; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -28,7 +37,9 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -45,15 +56,26 @@ class SftpControllerTest { @Mock private SftpService sftpService; - @InjectMocks private SftpController sftpController; + @TempDir + Path tempDir; + @BeforeEach void setUp() { User user = new User(); user.setId(1L); user.setUsername("alice"); when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + sftpController = new SftpController(connectionService, userRepository, sftpService, tempDir.toString()); + } + + @AfterEach + void tearDown() { + ExecutorService executor = (ExecutorService) ReflectionTestUtils.getField(sftpController, "transferTaskExecutor"); + if (executor != null) { + executor.shutdownNow(); + } } @Test @@ -133,17 +155,93 @@ class SftpControllerTest { assertTrue(response.getBody().get("error").contains("boom")); } + @Test + void uploadReturnsConflictWhenTargetFileExistsAndOverwriteDisabled() throws Exception { + when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection()); + SftpService.SftpSession session = connectedSession(true); + when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session); + when(sftpService.statIfExists(session, "/uploads/demo.txt")).thenReturn(new SftpService.PathInfo(false)); + + ResponseEntity> response = sftpController.upload( + 7L, + "/uploads", + false, + uploadFile("demo.txt"), + authentication() + ); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertEquals("SFTP_UPLOAD_CONFLICT", response.getBody().get("code")); + assertEquals("demo.txt", response.getBody().get("fileName")); + assertEquals("file", response.getBody().get("conflictType")); + assertEquals(Boolean.TRUE, response.getBody().get("canOverwrite")); + verify(sftpService, never()).upload(any(), anyString(), any(InputStream.class), any(SftpService.TransferProgressListener.class)); + try (Stream files = Files.list(tempDir)) { + assertEquals(0L, files.count()); + } + } + + @Test + void uploadAllowsOverwriteWhenTargetFileExistsAndOverwriteEnabled() throws Exception { + when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection()); + SftpService.SftpSession session = connectedSession(true); + when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session); + when(sftpService.statIfExists(session, "/uploads/demo.txt")).thenReturn(new SftpService.PathInfo(false)); + + ResponseEntity> response = sftpController.upload( + 7L, + "/uploads", + true, + uploadFile("demo.txt"), + authentication() + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().containsKey("taskId")); + verify(sftpService, timeout(1000)).upload( + eq(session), + eq("/uploads/demo.txt"), + any(InputStream.class), + any(SftpService.TransferProgressListener.class) + ); + } + + @Test + void uploadReturnsConflictWhenTargetDirectoryExistsEvenWithOverwriteEnabled() throws Exception { + when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection()); + SftpService.SftpSession session = connectedSession(true); + when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session); + when(sftpService.statIfExists(session, "/uploads/demo.txt")).thenReturn(new SftpService.PathInfo(true)); + + ResponseEntity> response = sftpController.upload( + 7L, + "/uploads", + true, + uploadFile("demo.txt"), + authentication() + ); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertEquals("dir", response.getBody().get("conflictType")); + assertEquals(Boolean.FALSE, response.getBody().get("canOverwrite")); + verify(sftpService, never()).upload(any(), anyString(), any(InputStream.class), any(SftpService.TransferProgressListener.class)); + } + private Authentication authentication() { Authentication authentication = mock(Authentication.class); when(authentication.getName()).thenReturn("alice"); return authentication; } + private MockMultipartFile uploadFile(String filename) { + return new MockMultipartFile("file", filename, "text/plain", "hello".getBytes(StandardCharsets.UTF_8)); + } + private SftpService.SftpSession connectedSession(boolean connected) { Session session = mock(Session.class); ChannelSftp channel = mock(ChannelSftp.class); if (connected) { - when(channel.isConnected()).thenReturn(true); + lenient().when(channel.isConnected()).thenReturn(true); } return new SftpService.SftpSession(session, channel); } diff --git a/backend/src/test/java/com/sshmanager/service/SftpServiceTest.java b/backend/src/test/java/com/sshmanager/service/SftpServiceTest.java index ece1aa0..863e3b7 100644 --- a/backend/src/test/java/com/sshmanager/service/SftpServiceTest.java +++ b/backend/src/test/java/com/sshmanager/service/SftpServiceTest.java @@ -1,5 +1,9 @@ package com.sshmanager.service; +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.Session; +import com.jcraft.jsch.SftpATTRS; +import com.jcraft.jsch.SftpException; import com.sshmanager.entity.Connection; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -8,6 +12,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class SftpServiceTest { @@ -48,4 +54,29 @@ class SftpServiceTest { executorService.shutdown(); assertTrue(executorService.isTerminated() || executorService.isShutdown()); } + + @Test + void statIfExistsReturnsNullWhenRemotePathIsMissing() throws Exception { + Session session = mock(Session.class); + ChannelSftp channel = mock(ChannelSftp.class); + when(channel.stat("/missing.txt")).thenThrow(new SftpException(ChannelSftp.SSH_FX_NO_SUCH_FILE, "missing")); + + SftpService.PathInfo result = sftpService.statIfExists(new SftpService.SftpSession(session, channel), "/missing.txt"); + + assertNull(result); + } + + @Test + void statIfExistsReturnsDirectoryFlagForExistingPath() throws Exception { + Session session = mock(Session.class); + ChannelSftp channel = mock(ChannelSftp.class); + SftpATTRS attrs = mock(SftpATTRS.class); + when(channel.stat("/existing")).thenReturn(attrs); + when(attrs.isDir()).thenReturn(true); + + SftpService.PathInfo result = sftpService.statIfExists(new SftpService.SftpSession(session, channel), "/existing"); + + assertNotNull(result); + assertTrue(result.directory); + } } diff --git a/frontend/src/components/SftpCreateDirectoryModal.tsx b/frontend/src/components/SftpCreateDirectoryModal.tsx new file mode 100644 index 0000000..b19ffa2 --- /dev/null +++ b/frontend/src/components/SftpCreateDirectoryModal.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react' +import Modal from './Modal' + +export default function SftpCreateDirectoryModal({ + open, + currentPath, + onClose, + onSubmit, +}: { + open: boolean + currentPath: string + onClose: () => void + onSubmit: (name: string) => Promise +}) { + const inputRef = useRef(null) + const [name, setName] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) return + + setName('') + setSubmitting(false) + setError(null) + + const timer = window.setTimeout(() => inputRef.current?.focus(), 0) + return () => window.clearTimeout(timer) + }, [open]) + + if (!open) return null + + async function handleSave() { + const trimmedName = name.trim() + if (!trimmedName) { + setError('请输入目录名称') + return + } + + setSubmitting(true) + setError(null) + try { + await onSubmit(trimmedName) + onClose() + } catch (err) { + const message = + (err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '创建目录失败' + setError(message) + } finally { + setSubmitting(false) + } + } + + function handleClose() { + if (submitting) return + onClose() + } + + return ( + + + + + } + > +
+
+
当前路径
+
{currentPath}
+
+ + + + {error ?
{error}
: null} +
+
+ ) +} diff --git a/frontend/src/components/SftpPane.tsx b/frontend/src/components/SftpPane.tsx index e0eabcf..d8ee3dd 100644 --- a/frontend/src/components/SftpPane.tsx +++ b/frontend/src/components/SftpPane.tsx @@ -1,8 +1,69 @@ -import { useEffect, useMemo, useState } from 'react' -import { ChevronRight, Download, FileText, Folder, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' -import { createDir, deleteFile, downloadFile, getPwd, listFiles } from '../services/sftp' -import { formatBytes, formatSftpDate, formatSftpPermissions } from '../lib/utils' -import type { Connection, SftpFileInfo } from '../types' +import { useEffect, useMemo, useRef, useState, type FocusEvent } from 'react' +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Download, + Eye, + EyeOff, + FileText, + Folder, + FolderPlus, + LoaderCircle, + RefreshCw, + Search, + Trash2, + Upload, + X, +} from 'lucide-react' +import { + createDir, + deleteFile, + downloadFile, + getPwd, + listFiles, + subscribeUploadProgress, + uploadFile, +} from '../services/sftp' +import { cn, formatBytes, formatSftpDate, formatSftpPermissions } from '../lib/utils' +import type { Connection, SftpFileInfo, UploadConflictResponse, UploadTask } from '../types' +import Modal from './Modal' +import SftpCreateDirectoryModal from './SftpCreateDirectoryModal' + +interface UploadQueueItem { + id: string + filename: string + status: 'queued' | 'running' | 'success' | 'error' | 'skipped' | 'cancelled' + progress: number + transferredBytes: number + totalBytes: number + message: string + createdAt: number + remoteTaskId?: string +} + +type SftpSortField = 'name' | 'mtime' +type SftpSortDirection = 'asc' | 'desc' +type UploadConflictAction = 'skip' | 'overwrite' | 'cancel' + +interface UploadConflictDialogState { + visible: boolean + fileName: string + fileType: 'file' | 'dir' + canOverwrite: boolean + queueId: string + message: string +} + +const emptyUploadConflictDialog: UploadConflictDialogState = { + visible: false, + fileName: '', + fileType: 'file', + canOverwrite: true, + queueId: '', + message: '', +} export default function SftpPane({ connection, @@ -15,14 +76,95 @@ export default function SftpPane({ onSelectedFilesChange: (files: string[]) => void onRefreshSignal?: (refresh: () => Promise) => void }) { + const fileInputRef = useRef(null) + const uploadMenuRef = useRef(null) + const uploadSubscriptionsRef = useRef void>>(new Map()) + const uploadConflictResolverRef = useRef<((decision: { action: UploadConflictAction; applyToAll: boolean }) => void) | null>(null) + const uploadDispatchInProgressRef = useRef(false) const [currentPath, setCurrentPath] = useState('/') const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [showHiddenFiles, setShowHiddenFiles] = useState(false) + const [uploadMenuOpen, setUploadMenuOpen] = useState(false) + const [uploadQueue, setUploadQueue] = useState([]) + const [createDirectoryModalOpen, setCreateDirectoryModalOpen] = useState(false) + const [activeToolbarField, setActiveToolbarField] = useState<'path' | 'search' | null>(null) + const [conflictDialog, setConflictDialog] = useState(emptyUploadConflictDialog) + const [applyToAll, setApplyToAll] = useState(false) + const [sortField, setSortField] = useState('name') + const [sortDirection, setSortDirection] = useState('asc') + const normalizedSearchQuery = searchQuery.trim().toLowerCase() + const visibleEntries = useMemo( + () => entries.filter((entry) => showHiddenFiles || !entry.name.startsWith('.')), + [entries, showHiddenFiles], + ) + const filteredEntries = useMemo(() => { + if (!normalizedSearchQuery) return visibleEntries + return visibleEntries.filter((entry) => entry.name.toLowerCase().includes(normalizedSearchQuery)) + }, [visibleEntries, normalizedSearchQuery]) + const sortedEntries = useMemo(() => { + return filteredEntries.slice().sort((left, right) => { + if (left.directory !== right.directory) { + return left.directory ? -1 : 1 + } + + const nameDiff = left.name.localeCompare(right.name, undefined, { + numeric: true, + sensitivity: 'base', + }) + + if (sortField === 'name') { + if (nameDiff !== 0) { + return sortDirection === 'asc' ? nameDiff : -nameDiff + } + + return sortDirection === 'asc' ? left.mtime - right.mtime : right.mtime - left.mtime + } + + const timeDiff = left.mtime - right.mtime + if (timeDiff !== 0) { + return sortDirection === 'asc' ? timeDiff : -timeDiff + } + + return nameDiff + }) + }, [filteredEntries, sortDirection, sortField]) + const visibleSelectedFiles = useMemo( + () => filteredEntries.filter((entry) => selectedFiles.includes(entry.name)), + [filteredEntries, selectedFiles], + ) const allSelected = useMemo( - () => entries.length > 0 && selectedFiles.length === entries.length, - [entries.length, selectedFiles.length], + () => filteredEntries.length > 0 && visibleSelectedFiles.length === filteredEntries.length, + [filteredEntries.length, visibleSelectedFiles.length], + ) + const activeUploads = useMemo( + () => uploadQueue.filter((item) => item.status === 'queued' || item.status === 'running').length, + [uploadQueue], + ) + const uploadMenuId = 'sftp-upload-menu' + const uploadButtonLabel = activeUploads > 0 ? `上传,当前有 ${activeUploads} 个任务进行中` : '上传' + const toolbarIconButtonClass = + 'relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-700/80 bg-slate-900/80 text-slate-400 transition hover:border-slate-600 hover:bg-slate-800 hover:text-white' + const toolbarActiveIconButtonClass = 'border-blue-500/50 bg-blue-500/10 text-blue-200' + const pathToolbarFieldClass = cn( + 'group flex h-9 min-w-0 shrink items-center overflow-hidden rounded-2xl border border-slate-700/80 bg-slate-950/90 px-3 text-sm text-slate-200 shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]', + activeToolbarField === 'path' + ? 'basis-[70%] grow-[7]' + : activeToolbarField === 'search' + ? 'basis-[42%] grow-[21]' + : 'basis-[60%] grow-[6]', + ) + const searchToolbarFieldClass = cn( + 'group relative h-9 min-w-0 shrink overflow-hidden rounded-2xl border border-slate-700/80 bg-slate-950/90 text-slate-200 shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]', + activeToolbarField === 'search' + ? 'basis-[58%] grow-[29]' + : activeToolbarField === 'path' + ? 'basis-[30%] grow-[3]' + : 'basis-[40%] grow-[4]', ) const refresh = async (nextPath?: string) => { @@ -46,6 +188,20 @@ export default function SftpPane({ useEffect(() => { let ignore = false ;(async () => { + setNotice(null) + setSearchQuery('') + setShowHiddenFiles(false) + setUploadQueue([]) + setCreateDirectoryModalOpen(false) + setActiveToolbarField(null) + resolveConflictDecision('cancel', false) + setConflictDialog(emptyUploadConflictDialog) + setApplyToAll(false) + uploadDispatchInProgressRef.current = false + setSortField('name') + setSortDirection('asc') + uploadSubscriptionsRef.current.forEach((unsubscribe) => unsubscribe()) + uploadSubscriptionsRef.current.clear() try { const pwd = await getPwd(connection.id) if (!ignore) { @@ -60,6 +216,10 @@ export default function SftpPane({ })() return () => { ignore = true + resolveConflictDecision('cancel', false) + uploadDispatchInProgressRef.current = false + uploadSubscriptionsRef.current.forEach((unsubscribe) => unsubscribe()) + uploadSubscriptionsRef.current.clear() } }, [connection.id]) @@ -67,65 +227,455 @@ export default function SftpPane({ onRefreshSignal?.(refresh) }, [onRefreshSignal]) + useEffect(() => { + if (showHiddenFiles) return + + const hiddenSelectedFiles = new Set( + entries.filter((entry) => entry.name.startsWith('.') && selectedFiles.includes(entry.name)).map((entry) => entry.name), + ) + + if (hiddenSelectedFiles.size === 0) return + + onSelectedFilesChange(selectedFiles.filter((name) => !hiddenSelectedFiles.has(name))) + }, [entries, onSelectedFilesChange, selectedFiles, showHiddenFiles]) + + useEffect(() => { + if (!uploadMenuOpen) return + + const handleClickOutside = (event: MouseEvent) => { + if (!uploadMenuRef.current?.contains(event.target as Node)) { + setUploadMenuOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [uploadMenuOpen]) + function joinPath(base: string, name: string) { return base === '/' ? `/${name}` : `${base.replace(/\/$/, '')}/${name}` } + function updateUploadItem(itemId: string, updater: (task: UploadQueueItem) => UploadQueueItem) { + setUploadQueue((prev) => prev.map((item) => (item.id === itemId ? updater(item) : item))) + } + + function addUploadQueueItem(file: File) { + const queueId = `${Date.now()}-${file.name}-${Math.random().toString(36).slice(2, 8)}` + setUploadQueue((prev) => [ + { + id: queueId, + filename: file.name, + status: 'queued', + progress: 0, + transferredBytes: 0, + totalBytes: file.size, + message: '等待上传', + createdAt: Date.now(), + }, + ...prev, + ]) + return queueId + } + + function getUploadConflict(error: unknown) { + const response = (error as { response?: { status?: number; data?: UploadConflictResponse } }).response + if (response?.status !== 409 || response.data?.code !== 'SFTP_UPLOAD_CONFLICT') { + return null + } + return response.data + } + + function resolveConflictDecision(action: UploadConflictAction, applyDecisionToAll = applyToAll) { + const resolver = uploadConflictResolverRef.current + uploadConflictResolverRef.current = null + setConflictDialog(emptyUploadConflictDialog) + setApplyToAll(false) + resolver?.({ action, applyToAll: applyDecisionToAll }) + } + + function waitForConflictDecision(conflict: UploadConflictResponse, queueId: string) { + setApplyToAll(false) + setConflictDialog({ + visible: true, + fileName: conflict.fileName, + fileType: conflict.conflictType, + canOverwrite: conflict.canOverwrite, + queueId, + message: conflict.message, + }) + + return new Promise<{ action: UploadConflictAction; applyToAll: boolean }>((resolve) => { + uploadConflictResolverRef.current = resolve + }) + } + async function handleDeleteMany() { const targets = entries.filter((entry) => selectedFiles.includes(entry.name)) await Promise.all(targets.map((entry) => deleteFile(connection.id, joinPath(currentPath, entry.name), entry.directory))) await refresh() } - async function handleCreateDir() { - const name = window.prompt('新建目录名称') - if (!name) return + async function handleCreateDir(name: string) { await createDir(connection.id, joinPath(currentPath, name)) await refresh() } + async function startQueuedUpload(file: File, targetPath: string, queueId: string, overwrite: boolean) { + const response = await uploadFile(connection.id, targetPath, file, { overwrite }) + const taskId = response.data.taskId + updateUploadItem(queueId, (item) => ({ + ...item, + remoteTaskId: taskId, + status: 'running', + message: overwrite ? '正在覆盖上传...' : '正在上传...', + })) + + const unsubscribe = subscribeUploadProgress(taskId, (task: UploadTask) => { + updateUploadItem(queueId, (item) => ({ + ...item, + remoteTaskId: task.taskId, + status: task.status, + progress: task.progress, + transferredBytes: task.transferredBytes, + totalBytes: task.totalBytes, + message: task.error || (task.status === 'success' ? '上传完成' : '正在上传...'), + })) + + if (task.status === 'success') { + void refresh() + } + + if (task.status === 'success' || task.status === 'error') { + uploadSubscriptionsRef.current.get(taskId)?.() + uploadSubscriptionsRef.current.delete(taskId) + } + }) + + uploadSubscriptionsRef.current.set(taskId, unsubscribe) + } + + async function handleStartUpload(files: File[]) { + if (files.length === 0) return + if (uploadDispatchInProgressRef.current) { + setNotice('当前上传批次仍在处理中,请先完成冲突选择。') + return + } + + const targetPath = currentPath + const batchDecision: { applyToAll: boolean; action: Exclude | null } = { + applyToAll: false, + action: null, + } + + uploadDispatchInProgressRef.current = true + setApplyToAll(false) + setNotice(`已开始上传到 ${targetPath}`) + + try { + for (const file of files) { + const queueId = addUploadQueueItem(file) + let overwrite = batchDecision.applyToAll && batchDecision.action === 'overwrite' + + while (true) { + try { + await startQueuedUpload(file, targetPath, queueId, overwrite) + break + } catch (err) { + const conflict = getUploadConflict(err) + if (!conflict) { + const message = + (err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || + (err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.error || + '上传失败' + updateUploadItem(queueId, (item) => ({ + ...item, + status: 'error', + message, + })) + break + } + + updateUploadItem(queueId, (item) => ({ + ...item, + status: 'queued', + message: conflict.canOverwrite ? '等待冲突处理' : '检测到同名文件夹,等待处理', + })) + + let decision: { action: UploadConflictAction; applyToAll: boolean } + if (batchDecision.applyToAll && batchDecision.action && (batchDecision.action !== 'overwrite' || conflict.canOverwrite)) { + decision = { + action: batchDecision.action, + applyToAll: true, + } + } else { + decision = await waitForConflictDecision(conflict, queueId) + } + + if (decision.action === 'cancel') { + batchDecision.applyToAll = false + batchDecision.action = null + updateUploadItem(queueId, (item) => ({ + ...item, + status: 'cancelled', + message: '已取消上传', + })) + setNotice('已取消当前上传批次,剩余文件未开始上传。') + return + } + + if (decision.applyToAll) { + batchDecision.applyToAll = true + batchDecision.action = decision.action + } else { + batchDecision.applyToAll = false + batchDecision.action = null + } + + if (decision.action === 'skip') { + updateUploadItem(queueId, (item) => ({ + ...item, + status: 'skipped', + message: '已跳过冲突文件', + })) + break + } + + overwrite = true + updateUploadItem(queueId, (item) => ({ + ...item, + status: 'queued', + message: '准备覆盖上传', + })) + } + } + } + } finally { + uploadDispatchInProgressRef.current = false + setApplyToAll(false) + } + } + + function handleUploadFolderPlaceholder() { + setUploadMenuOpen(false) + setNotice('后端暂未支持文件夹上传,后续实现。') + } + + function handleToolbarFieldBlur(field: 'path' | 'search', event: FocusEvent) { + const nextTarget = event.relatedTarget + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return + } + + setActiveToolbarField((current) => (current === field ? null : current)) + } + + function handleSortChange(field: SftpSortField) { + if (field === sortField) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) + return + } + + setSortField(field) + setSortDirection(field === 'mtime' ? 'desc' : 'asc') + } + + function getAriaSort(field: SftpSortField) { + if (sortField !== field) return 'none' + return sortDirection === 'asc' ? 'ascending' : 'descending' + } + + function clearFinishedUploads() { + setUploadQueue((prev) => prev.filter((item) => item.status === 'queued' || item.status === 'running')) + } + return (
-
- -
- - setCurrentPath(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') void refresh(currentPath) + { + const files = Array.from(event.target.files ?? []) + void handleStartUpload(files) + event.currentTarget.value = '' + }} + /> + +
+
+ +
setActiveToolbarField('path')} + onBlurCapture={(event) => handleToolbarFieldBlur('path', event)} + > + + setCurrentPath(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void refresh(currentPath) + }} + /> +
+
setActiveToolbarField('search')} + onBlurCapture={(event) => handleToolbarFieldBlur('search', event)} + > + + setSearchQuery(event.target.value)} + className="h-full w-full min-w-0 bg-transparent py-2 pl-9 pr-8 text-xs text-slate-200 outline-none placeholder:text-slate-500" + /> + {searchQuery ? ( + + ) : null} +
-
- - - + {uploadMenuOpen ? ( + + ) : null} +
+ + +
+ {notice ? ( +
+ {notice} +
+ ) : null} + {selectedFiles.length > 0 ? (
已选择 {selectedFiles.length} 项
- @@ -137,21 +687,73 @@ export default function SftpPane({
) : null} -
- - +
+
+ - - - - - + + + + @@ -169,8 +771,31 @@ export default function SftpPane({ ) : null} + {!loading && !error && filteredEntries.length === 0 ? ( + + + + ) : null} {!loading && !error - ? entries.map((entry) => { + ? sortedEntries.map((entry) => { const checked = selectedFiles.includes(entry.name) return ( {entry.name}
- -
- + ) @@ -215,6 +852,132 @@ export default function SftpPane({
+ onSelectedFilesChange(allSelected ? [] : entries.map((entry) => entry.name))} + onChange={() => + onSelectedFilesChange( + allSelected + ? selectedFiles.filter((name) => !filteredEntries.some((entry) => entry.name === name)) + : Array.from(new Set([...selectedFiles, ...filteredEntries.map((entry) => entry.name)])), + ) + } /> 文件名大小修改时间权限 + + 大小 + + 权限
+
+ +
+ {entries.length === 0 + ? '当前目录为空' + : visibleEntries.length === 0 && !normalizedSearchQuery + ? '当前目录暂无可见文件' + : '未找到匹配文件'} +
+
+ {entries.length === 0 + ? '拖拽文件到这里,或使用上方“上传文件...”入口开始传输。' + : visibleEntries.length === 0 && !normalizedSearchQuery + ? '当前目录内容均为隐藏文件,可点击上方眼睛按钮临时显示。' + : '尝试更换关键词,或清空搜索后查看当前目录全部内容。'} +
+
+
{entry.directory ? '-' : formatBytes(entry.size)}{formatSftpDate(entry.mtime)} + {formatSftpDate(entry.mtime)} + {formatSftpPermissions(entry)}
+ + {uploadQueue.length > 0 ? ( +
+
+
+
+
上传队列
+
单文件上传已接入现有接口与进度流。
+
+ +
+
+ {uploadQueue.map((item) => ( +
+
+
+
{item.filename}
+
+ {item.status === 'running' || item.status === 'queued' ? ( + + ) : item.status === 'success' ? ( + + ) : item.status === 'skipped' || item.status === 'cancelled' ? ( + + ) : ( + + )} + {item.message} +
+
+ {item.progress}% +
+
+
+
+
+ {formatBytes(item.transferredBytes)} / {formatBytes(item.totalBytes)} +
+
+ ))} +
+
+
+ ) : null} + + {conflictDialog.visible ? ( + resolveConflictDecision('cancel', false)} + footer={ + <> + + + + + } + > +
+ +
+

+ 目标目录中已存在名为 {conflictDialog.fileName} 的 + {conflictDialog.fileType === 'dir' ? '文件夹' : '文件'}。 +

+

{conflictDialog.message}

+ {conflictDialog.canOverwrite ? ( +

请选择要执行的操作:覆盖原有文件,或者跳过该传输任务。

+ ) : ( +

同名文件夹无法直接覆盖,请选择跳过该文件或取消当前批次。

+ )} + +
+
+
+ ) : null} + + setCreateDirectoryModalOpen(false)} + onSubmit={handleCreateDir} + />
) } diff --git a/frontend/src/components/TransferCenterModal.tsx b/frontend/src/components/TransferCenterModal.tsx index 2322783..284b099 100644 --- a/frontend/src/components/TransferCenterModal.tsx +++ b/frontend/src/components/TransferCenterModal.tsx @@ -69,7 +69,7 @@ export default function TransferCenterModal({ onTasksChange([group, ...tasks]) targetIds.forEach(async (targetId) => { - const response = await uploadFile(targetId, localTargetPath, file) + const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true }) const taskId = response.data.taskId updateTaskGroup(groupId, (current) => ({ ...current, diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 519080a..728f9ee 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -28,8 +28,20 @@ export function formatTimestamp(timestamp?: number | null) { return new Date(timestamp).toLocaleString() } -export function formatSftpDate(epochSeconds: number) { - return new Date(epochSeconds * 1000).toLocaleString() +export function formatSftpDate(timestampMs?: number | null) { + if (timestampMs == null || Number.isNaN(timestampMs)) return '-' + + const date = new Date(timestampMs) + if (Number.isNaN(date.getTime())) return '-' + + const year = String(date.getFullYear()) + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` } export function formatSftpPermissions(entry: { directory: boolean }) { diff --git a/frontend/src/services/sftp.ts b/frontend/src/services/sftp.ts index e36c58c..b37e131 100644 --- a/frontend/src/services/sftp.ts +++ b/frontend/src/services/sftp.ts @@ -27,11 +27,11 @@ export async function downloadFile(connectionId: number, path: string, downloadN URL.revokeObjectURL(url) } -export function uploadFile(connectionId: number, path: string, file: File) { +export function uploadFile(connectionId: number, path: string, file: File, options?: { overwrite?: boolean }) { const form = new FormData() form.append('file', file, file.name) return http.post<{ taskId: string; message: string }>('/sftp/upload', form, { - params: { connectionId, path }, + params: { connectionId, path, overwrite: options?.overwrite ?? false }, }) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index da57baf..1bc47a4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -78,6 +78,14 @@ export interface UploadTask { finishedAt: number } +export interface UploadConflictResponse { + code: 'SFTP_UPLOAD_CONFLICT' + fileName: string + conflictType: 'file' | 'dir' + canOverwrite: boolean + message: string +} + export interface RemoteTransferTask { taskId: string status: 'queued' | 'running' | 'success' | 'error' | 'cancelled'