feat: refine sftp pane upload workflow

This commit is contained in:
liumangmang
2026-04-22 17:59:07 +08:00
parent 423cca97a6
commit 165cc0e35b
10 changed files with 1188 additions and 74 deletions
@@ -40,6 +40,7 @@ import java.util.stream.Stream;
public class SftpController { public class SftpController {
private static final Logger log = LoggerFactory.getLogger(SftpController.class); 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 ConnectionService connectionService;
private final UserRepository userRepository; private final UserRepository userRepository;
@@ -292,11 +293,32 @@ public class SftpController {
public ResponseEntity<Map<String, Object>> upload( public ResponseEntity<Map<String, Object>> upload(
@RequestParam Long connectionId, @RequestParam Long connectionId,
@RequestParam String path, @RequestParam String path,
@RequestParam(defaultValue = "false") boolean overwrite,
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
Authentication authentication) { Authentication authentication) {
java.io.File tempFile = null; java.io.File tempFile = null;
try { try {
Long userId = getCurrentUserId(authentication); 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 taskId = UUID.randomUUID().toString();
String taskKey = uploadTaskKey(userId, taskId); String taskKey = uploadTaskKey(userId, taskId);
@@ -305,25 +327,25 @@ public class SftpController {
if (!uploadTempDir.exists() && !uploadTempDir.mkdirs()) { if (!uploadTempDir.exists() && !uploadTempDir.mkdirs()) {
throw new IOException("Failed to create upload temp directory: " + uploadTempDir.getAbsolutePath()); 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); file.transferTo(tempFile);
final java.io.File savedFile = tempFile; final java.io.File savedFile = tempFile;
UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId, UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId,
path, file.getOriginalFilename(), file.getSize()); path, filename, file.getSize());
status.setController(this); status.setController(this);
uploadTasks.put(taskKey, status); uploadTasks.put(taskKey, status);
Future<?> future = transferTaskExecutor.submit(() -> { Future<?> future = transferTaskExecutor.submit(() -> {
status.setStatus("running"); status.setStatus("running");
String key = sessionKey(userId, connectionId);
try { try {
withSessionLock(key, () -> { withSessionLock(key, () -> {
try { try {
SftpService.SftpSession session = getOrCreateSession(connectionId, userId); SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
String remotePath = (path == null || path.isEmpty() || path.equals("/")) UploadConflictInfo conflict = detectUploadConflict(session, remotePath, filename, overwrite);
? "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) if (conflict != null) {
: (path.endsWith("/") ? path + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) : path + "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1)); throw new IllegalStateException(conflict.message);
}
AtomicLong transferred = new AtomicLong(0); AtomicLong transferred = new AtomicLong(0);
try (java.io.InputStream in = new java.io.FileInputStream(savedFile)) { 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<Map<String, Object>> buildUploadConflictResponse(UploadConflictInfo conflict) {
Map<String, Object> 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") @DeleteMapping("/delete")
public ResponseEntity<Map<String, String>> delete( public ResponseEntity<Map<String, String>> delete(
@RequestParam Long connectionId, @RequestParam Long connectionId,
@@ -739,6 +797,20 @@ public class SftpController {
private final SftpSessionExpiryCleanup cleanupTask = new SftpSessionExpiryCleanup(); 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 { public static class SftpSessionExpiryCleanup {
private final Map<String, Long> lastAccessTime = new ConcurrentHashMap<>(); private final Map<String, Long> lastAccessTime = new ConcurrentHashMap<>();
@@ -104,6 +104,14 @@ public class SftpService {
} }
} }
public static class PathInfo {
public final boolean directory;
public PathInfo(boolean directory) {
this.directory = directory;
}
}
public interface TransferProgressListener { public interface TransferProgressListener {
void onStart(long totalBytes); void onStart(long totalBytes);
@@ -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;
}
}
} }
@@ -10,15 +10,24 @@ import com.sshmanager.service.SftpService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; 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.Map;
import java.util.Optional; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; 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.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -45,15 +56,26 @@ class SftpControllerTest {
@Mock @Mock
private SftpService sftpService; private SftpService sftpService;
@InjectMocks
private SftpController sftpController; private SftpController sftpController;
@TempDir
Path tempDir;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
User user = new User(); User user = new User();
user.setId(1L); user.setId(1L);
user.setUsername("alice"); user.setUsername("alice");
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); 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 @Test
@@ -133,17 +155,93 @@ class SftpControllerTest {
assertTrue(response.getBody().get("error").contains("boom")); 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<Map<String, Object>> 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<Path> 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<Map<String, Object>> 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<Map<String, Object>> 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() { private Authentication authentication() {
Authentication authentication = mock(Authentication.class); Authentication authentication = mock(Authentication.class);
when(authentication.getName()).thenReturn("alice"); when(authentication.getName()).thenReturn("alice");
return authentication; 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) { private SftpService.SftpSession connectedSession(boolean connected) {
Session session = mock(Session.class); Session session = mock(Session.class);
ChannelSftp channel = mock(ChannelSftp.class); ChannelSftp channel = mock(ChannelSftp.class);
if (connected) { if (connected) {
when(channel.isConnected()).thenReturn(true); lenient().when(channel.isConnected()).thenReturn(true);
} }
return new SftpService.SftpSession(session, channel); return new SftpService.SftpSession(session, channel);
} }
@@ -1,5 +1,9 @@
package com.sshmanager.service; 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 com.sshmanager.entity.Connection;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -8,6 +12,8 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SftpServiceTest { class SftpServiceTest {
@@ -48,4 +54,29 @@ class SftpServiceTest {
executorService.shutdown(); executorService.shutdown();
assertTrue(executorService.isTerminated() || executorService.isShutdown()); 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);
}
} }
@@ -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<void>
}) {
const inputRef = useRef<HTMLInputElement | null>(null)
const [name, setName] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Modal
title="新建目录"
onClose={submitting ? undefined : handleClose}
maxWidth="max-w-md"
footer={
<>
<button
className="rounded-xl bg-slate-700 px-4 py-2 text-sm text-slate-200 transition hover:bg-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
disabled={submitting}
onClick={handleClose}
>
</button>
<button
className="rounded-xl bg-blue-600 px-4 py-2 text-sm text-white transition hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
disabled={submitting}
onClick={handleSave}
>
{submitting ? '创建中...' : '确定'}
</button>
</>
}
>
<div className="space-y-5">
<div className="rounded-2xl border border-slate-800 bg-slate-950/50 px-4 py-3">
<div className="text-xs uppercase tracking-[0.24em] text-slate-500"></div>
<div className="mt-2 break-all font-mono text-sm text-slate-200">{currentPath}</div>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300"></span>
<input
ref={inputRef}
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none transition focus:border-blue-500"
placeholder="例如:releases"
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleSave()
}
}}
/>
</label>
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
</div>
</Modal>
)
}
+815 -52
View File
@@ -1,8 +1,69 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState, type FocusEvent } from 'react'
import { ChevronRight, Download, FileText, Folder, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import {
import { createDir, deleteFile, downloadFile, getPwd, listFiles } from '../services/sftp' AlertCircle,
import { formatBytes, formatSftpDate, formatSftpPermissions } from '../lib/utils' CheckCircle2,
import type { Connection, SftpFileInfo } from '../types' 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({ export default function SftpPane({
connection, connection,
@@ -15,14 +76,95 @@ export default function SftpPane({
onSelectedFilesChange: (files: string[]) => void onSelectedFilesChange: (files: string[]) => void
onRefreshSignal?: (refresh: () => Promise<void>) => void onRefreshSignal?: (refresh: () => Promise<void>) => void
}) { }) {
const fileInputRef = useRef<HTMLInputElement | null>(null)
const uploadMenuRef = useRef<HTMLDivElement | null>(null)
const uploadSubscriptionsRef = useRef<Map<string, () => void>>(new Map())
const uploadConflictResolverRef = useRef<((decision: { action: UploadConflictAction; applyToAll: boolean }) => void) | null>(null)
const uploadDispatchInProgressRef = useRef(false)
const [currentPath, setCurrentPath] = useState('/') const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState<SftpFileInfo[]>([]) const [entries, setEntries] = useState<SftpFileInfo[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [showHiddenFiles, setShowHiddenFiles] = useState(false)
const [uploadMenuOpen, setUploadMenuOpen] = useState(false)
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([])
const [createDirectoryModalOpen, setCreateDirectoryModalOpen] = useState(false)
const [activeToolbarField, setActiveToolbarField] = useState<'path' | 'search' | null>(null)
const [conflictDialog, setConflictDialog] = useState<UploadConflictDialogState>(emptyUploadConflictDialog)
const [applyToAll, setApplyToAll] = useState(false)
const [sortField, setSortField] = useState<SftpSortField>('name')
const [sortDirection, setSortDirection] = useState<SftpSortDirection>('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( const allSelected = useMemo(
() => entries.length > 0 && selectedFiles.length === entries.length, () => filteredEntries.length > 0 && visibleSelectedFiles.length === filteredEntries.length,
[entries.length, selectedFiles.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) => { const refresh = async (nextPath?: string) => {
@@ -46,6 +188,20 @@ export default function SftpPane({
useEffect(() => { useEffect(() => {
let ignore = false let ignore = false
;(async () => { ;(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 { try {
const pwd = await getPwd(connection.id) const pwd = await getPwd(connection.id)
if (!ignore) { if (!ignore) {
@@ -60,6 +216,10 @@ export default function SftpPane({
})() })()
return () => { return () => {
ignore = true ignore = true
resolveConflictDecision('cancel', false)
uploadDispatchInProgressRef.current = false
uploadSubscriptionsRef.current.forEach((unsubscribe) => unsubscribe())
uploadSubscriptionsRef.current.clear()
} }
}, [connection.id]) }, [connection.id])
@@ -67,65 +227,455 @@ export default function SftpPane({
onRefreshSignal?.(refresh) onRefreshSignal?.(refresh)
}, [onRefreshSignal]) }, [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) { function joinPath(base: string, name: string) {
return base === '/' ? `/${name}` : `${base.replace(/\/$/, '')}/${name}` 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() { async function handleDeleteMany() {
const targets = entries.filter((entry) => selectedFiles.includes(entry.name)) 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 Promise.all(targets.map((entry) => deleteFile(connection.id, joinPath(currentPath, entry.name), entry.directory)))
await refresh() await refresh()
} }
async function handleCreateDir() { async function handleCreateDir(name: string) {
const name = window.prompt('新建目录名称')
if (!name) return
await createDir(connection.id, joinPath(currentPath, name)) await createDir(connection.id, joinPath(currentPath, name))
await refresh() 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<UploadConflictAction, 'cancel'> | 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<HTMLDivElement>) {
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 ( return (
<div className="relative flex h-full flex-col bg-slate-900"> <div className="relative flex h-full flex-col bg-slate-900">
<div className="flex h-10 items-center gap-2 border-b border-slate-800 bg-slate-800/80 px-3"> <input
<button ref={fileInputRef}
className="rounded p-1 text-slate-400 transition hover:bg-slate-700 hover:text-white" type="file"
onClick={() => { multiple
if (currentPath === '/') return className="hidden"
const parent = currentPath.split('/').slice(0, -1).join('/') || '/' onChange={(event) => {
void refresh(parent) const files = Array.from(event.target.files ?? [])
}} void handleStartUpload(files)
> event.currentTarget.value = ''
<ChevronRight className="rotate-180" size={16} /> }}
</button> />
<div className="flex flex-1 items-center rounded-xl border border-slate-700 bg-slate-950 px-3 py-1 text-sm text-slate-200">
<Folder size={14} className="mr-2 text-blue-400" /> <div className="flex min-h-14 flex-nowrap items-center gap-2 border-b border-slate-800 bg-slate-800/80 px-3 py-2">
<input <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
className="w-full bg-transparent outline-none" <button
value={currentPath} className="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 disabled:cursor-not-allowed disabled:opacity-40"
onChange={(event) => setCurrentPath(event.target.value)} onClick={() => {
onKeyDown={(event) => { if (currentPath === '/') return
if (event.key === 'Enter') void refresh(currentPath) const parent = currentPath.split('/').slice(0, -1).join('/') || '/'
void refresh(parent)
}} }}
/> disabled={currentPath === '/'}
title="返回上级目录"
aria-label="返回上级目录"
>
<ChevronRight className="rotate-180" size={16} />
</button>
<div
className={pathToolbarFieldClass}
onFocusCapture={() => setActiveToolbarField('path')}
onBlurCapture={(event) => handleToolbarFieldBlur('path', event)}
>
<Folder
size={14}
className="mr-2 shrink-0 text-blue-400 transition-colors group-focus-within:text-blue-300"
/>
<input
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-slate-500"
value={currentPath}
onChange={(event) => setCurrentPath(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void refresh(currentPath)
}}
/>
</div>
<div
className={searchToolbarFieldClass}
onFocusCapture={() => setActiveToolbarField('search')}
onBlurCapture={(event) => handleToolbarFieldBlur('search', event)}
>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors group-focus-within:text-blue-400"
/>
<input
type="text"
placeholder="搜索文件..."
value={searchQuery}
onChange={(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 ? (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-slate-800 hover:text-slate-300"
title="清空搜索"
aria-label="清空搜索"
>
<X size={12} />
</button>
) : null}
</div>
</div> </div>
<div className="ml-1 flex gap-1 border-l border-slate-700 pl-2"> <div className="ml-auto flex shrink-0 items-center gap-1.5 rounded-2xl border border-slate-700/80 bg-slate-950/70 p-1">
<button className="rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-emerald-400"> <div className="relative" ref={uploadMenuRef}>
<Upload size={16} /> <button
</button> className={cn(
<button className="rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-blue-400" onClick={handleCreateDir}> toolbarIconButtonClass,
<Plus size={16} /> uploadMenuOpen || activeUploads > 0
</button> ? toolbarActiveIconButtonClass
<button className="rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-white" onClick={() => void refresh()}> : undefined,
)}
onClick={() => setUploadMenuOpen((prev) => !prev)}
title="上传"
aria-label={uploadButtonLabel}
aria-haspopup="menu"
aria-controls={uploadMenuId}
aria-expanded={uploadMenuOpen}
>
<Upload size={16} />
<ChevronDown
size={11}
aria-hidden="true"
className={cn(
'absolute bottom-1 right-1 text-slate-500 transition',
uploadMenuOpen || activeUploads > 0 ? 'text-blue-200' : 'text-slate-500',
uploadMenuOpen && 'rotate-180',
)}
/>
{activeUploads > 0 ? (
<span
aria-hidden="true"
className="absolute -right-1 -top-1 rounded-full bg-blue-500/20 px-1.5 py-0.5 text-[10px] leading-none text-blue-100"
>
{activeUploads}
</span>
) : null}
</button>
{uploadMenuOpen ? (
<div
id={uploadMenuId}
role="menu"
className="absolute right-0 top-[calc(100%+8px)] z-20 w-44 overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl shadow-black/40"
>
<button
role="menuitem"
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-slate-200 transition hover:bg-slate-800"
onClick={() => {
setUploadMenuOpen(false)
fileInputRef.current?.click()
}}
>
<Upload size={14} className="text-blue-400" />
...
</button>
<button
role="menuitem"
className="flex w-full items-center gap-2 border-t border-slate-800 px-4 py-3 text-left text-sm text-slate-200 transition hover:bg-slate-800"
onClick={handleUploadFolderPlaceholder}
>
<Folder size={14} className="text-amber-400" />
...
</button>
</div>
) : null}
</div>
<button
className={toolbarIconButtonClass}
onClick={() => void refresh()}
title="刷新目录"
aria-label="刷新目录"
>
<RefreshCw size={16} /> <RefreshCw size={16} />
</button> </button>
<button
className={cn(
toolbarIconButtonClass,
showHiddenFiles ? toolbarActiveIconButtonClass : undefined,
)}
onClick={() => setShowHiddenFiles((prev) => !prev)}
title={showHiddenFiles ? '关闭隐藏文件显示' : '显示隐藏文件'}
aria-label={showHiddenFiles ? '关闭隐藏文件显示' : '显示隐藏文件'}
aria-pressed={showHiddenFiles}
>
{showHiddenFiles ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
<button
className={toolbarIconButtonClass}
onClick={() => setCreateDirectoryModalOpen(true)}
title="新建目录"
aria-label="新建目录"
>
<FolderPlus size={16} />
</button>
</div> </div>
</div> </div>
{notice ? (
<div className="border-b border-blue-900/30 bg-blue-950/20 px-4 py-2 text-sm text-blue-200">
{notice}
</div>
) : null}
{selectedFiles.length > 0 ? ( {selectedFiles.length > 0 ? (
<div className="flex h-10 items-center justify-between border-b border-blue-900/50 bg-blue-900/15 px-4 text-sm"> <div className="flex h-10 items-center justify-between border-b border-blue-900/50 bg-blue-900/15 px-4 text-sm">
<span className="text-blue-300"> {selectedFiles.length} </span> <span className="text-blue-300"> {selectedFiles.length} </span>
<div className="flex items-center gap-3 text-slate-300"> <div className="flex items-center gap-3 text-slate-300">
<button onClick={() => entries.filter((entry) => selectedFiles.includes(entry.name)).forEach((entry) => void downloadFile(connection.id, joinPath(currentPath, entry.name), entry.name))} className="flex items-center gap-1 hover:text-white"> <button
onClick={() =>
entries
.filter((entry) => selectedFiles.includes(entry.name))
.forEach((entry) => void downloadFile(connection.id, joinPath(currentPath, entry.name), entry.name))
}
className="flex items-center gap-1 hover:text-white"
>
<Download size={14} /> <Download size={14} />
</button> </button>
@@ -137,21 +687,73 @@ export default function SftpPane({
</div> </div>
) : null} ) : null}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto isolate">
<table className="w-full text-left text-sm text-slate-300"> <table className="min-w-[48rem] w-full text-left text-sm text-slate-300">
<thead className="sticky top-0 bg-slate-800/95 text-xs text-slate-400"> <thead className="text-xs text-slate-400">
<tr> <tr>
<th className="w-10 px-4 py-3"> <th className="sticky top-0 z-20 w-10 border-b border-slate-700 bg-slate-800 px-4 py-3">
<input <input
type="checkbox" type="checkbox"
checked={allSelected} checked={allSelected}
onChange={() => 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)])),
)
}
/> />
</th> </th>
<th className="px-2 py-3"></th> <th className="sticky top-0 z-20 border-b border-slate-700 bg-slate-800 px-2 py-3" aria-sort={getAriaSort('name')}>
<th className="w-24 px-4 py-3"></th> <button
<th className="w-40 px-4 py-3"></th> type="button"
<th className="w-28 px-4 py-3"></th> className={cn(
'inline-flex items-center gap-1.5 rounded-md transition hover:text-white',
sortField === 'name' ? 'text-blue-300' : undefined,
)}
onClick={() => handleSortChange('name')}
title={sortField === 'name' && sortDirection === 'asc' ? '按文件名降序排序' : '按文件名升序排序'}
aria-label={sortField === 'name' && sortDirection === 'asc' ? '按文件名降序排序' : '按文件名升序排序'}
>
<span></span>
{sortField === 'name' ? (
<ChevronDown
size={13}
className={cn('transition', sortDirection === 'asc' ? 'rotate-180' : undefined)}
/>
) : (
<span aria-hidden="true" className="text-[10px] text-slate-600">
</span>
)}
</button>
</th>
<th className="sticky top-0 z-20 w-24 border-b border-slate-700 bg-slate-800 px-4 py-3"></th>
<th className="sticky top-0 z-20 w-44 border-b border-slate-700 bg-slate-800 px-4 py-3" aria-sort={getAriaSort('mtime')}>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-md whitespace-nowrap transition hover:text-white',
sortField === 'mtime' ? 'text-blue-300' : undefined,
)}
onClick={() => handleSortChange('mtime')}
title={sortField === 'mtime' && sortDirection === 'desc' ? '按修改时间从旧到新排序' : '按修改时间从新到旧排序'}
aria-label={sortField === 'mtime' && sortDirection === 'desc' ? '按修改时间从旧到新排序' : '按修改时间从新到旧排序'}
>
<span></span>
{sortField === 'mtime' ? (
<ChevronDown
size={13}
className={cn('transition', sortDirection === 'asc' ? 'rotate-180' : undefined)}
/>
) : (
<span aria-hidden="true" className="text-[10px] text-slate-600">
</span>
)}
</button>
</th>
<th className="sticky top-0 z-20 w-28 border-b border-slate-700 bg-slate-800 px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -169,8 +771,31 @@ export default function SftpPane({
</td> </td>
</tr> </tr>
) : null} ) : null}
{!loading && !error && filteredEntries.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-16 text-center text-slate-500">
<div className="mx-auto flex max-w-sm flex-col items-center gap-3 rounded-[28px] border border-dashed border-slate-700 bg-slate-950/40 px-6 py-8">
<Upload size={22} className="text-blue-400" />
<div className="text-sm text-slate-300">
{entries.length === 0
? '当前目录为空'
: visibleEntries.length === 0 && !normalizedSearchQuery
? '当前目录暂无可见文件'
: '未找到匹配文件'}
</div>
<div className="text-xs text-slate-500">
{entries.length === 0
? '拖拽文件到这里,或使用上方“上传文件...”入口开始传输。'
: visibleEntries.length === 0 && !normalizedSearchQuery
? '当前目录内容均为隐藏文件,可点击上方眼睛按钮临时显示。'
: '尝试更换关键词,或清空搜索后查看当前目录全部内容。'}
</div>
</div>
</td>
</tr>
) : null}
{!loading && !error {!loading && !error
? entries.map((entry) => { ? sortedEntries.map((entry) => {
const checked = selectedFiles.includes(entry.name) const checked = selectedFiles.includes(entry.name)
return ( return (
<tr <tr
@@ -197,16 +822,28 @@ export default function SftpPane({
<span className="truncate">{entry.name}</span> <span className="truncate">{entry.name}</span>
</div> </div>
<div className="absolute right-2 top-1/2 hidden -translate-y-1/2 gap-1 rounded-lg border border-slate-700 bg-slate-900 p-1 group-hover:flex"> <div className="absolute right-2 top-1/2 hidden -translate-y-1/2 gap-1 rounded-lg border border-slate-700 bg-slate-900 p-1 group-hover:flex">
<button className="p-1 text-slate-400 hover:text-blue-300" onClick={() => void downloadFile(connection.id, joinPath(currentPath, entry.name), entry.name)}> <button
className="p-1 text-slate-400 hover:text-blue-300"
onClick={() => void downloadFile(connection.id, joinPath(currentPath, entry.name), entry.name)}
title={`下载 ${entry.name}`}
aria-label={`下载 ${entry.name}`}
>
<Download size={13} /> <Download size={13} />
</button> </button>
<button className="p-1 text-slate-400 hover:text-red-400" onClick={() => void deleteFile(connection.id, joinPath(currentPath, entry.name), entry.directory).then(() => refresh())}> <button
className="p-1 text-slate-400 hover:text-red-400"
onClick={() => void deleteFile(connection.id, joinPath(currentPath, entry.name), entry.directory).then(() => refresh())}
title={`删除 ${entry.name}`}
aria-label={`删除 ${entry.name}`}
>
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-slate-400">{entry.directory ? '-' : formatBytes(entry.size)}</td> <td className="px-4 py-3 text-slate-400">{entry.directory ? '-' : formatBytes(entry.size)}</td>
<td className="px-4 py-3 text-slate-400">{formatSftpDate(entry.mtime)}</td> <td className="whitespace-nowrap px-4 py-3 font-mono text-[11px] tabular-nums text-slate-400">
{formatSftpDate(entry.mtime)}
</td>
<td className="px-4 py-3 font-mono text-xs text-slate-500">{formatSftpPermissions(entry)}</td> <td className="px-4 py-3 font-mono text-xs text-slate-500">{formatSftpPermissions(entry)}</td>
</tr> </tr>
) )
@@ -215,6 +852,132 @@ export default function SftpPane({
</tbody> </tbody>
</table> </table>
</div> </div>
{uploadQueue.length > 0 ? (
<div className="pointer-events-none absolute bottom-4 right-4 z-10 w-[min(360px,calc(100%-2rem))]">
<div className="pointer-events-auto overflow-hidden rounded-[28px] border border-slate-700 bg-slate-950/95 shadow-2xl shadow-black/30 backdrop-blur">
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3">
<div>
<div className="text-sm font-medium text-slate-100"></div>
<div className="text-xs text-slate-500"></div>
</div>
<button className="text-xs text-slate-400 transition hover:text-white" onClick={clearFinishedUploads}>
</button>
</div>
<div className="max-h-72 space-y-3 overflow-auto p-3">
{uploadQueue.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-800 bg-slate-900/80 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm text-slate-100">{item.filename}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-slate-500">
{item.status === 'running' || item.status === 'queued' ? (
<LoaderCircle size={12} className="animate-spin text-blue-400" />
) : item.status === 'success' ? (
<CheckCircle2 size={12} className="text-emerald-400" />
) : item.status === 'skipped' || item.status === 'cancelled' ? (
<X size={12} className="text-slate-400" />
) : (
<AlertCircle size={12} className="text-red-400" />
)}
<span>{item.message}</span>
</div>
</div>
<span className="text-xs text-slate-400">{item.progress}%</span>
</div>
<div className="mt-3 h-2 rounded-full bg-slate-800">
<div
className={cn(
'h-2 rounded-full transition-all',
item.status === 'success'
? 'bg-emerald-500'
: item.status === 'skipped' || item.status === 'cancelled'
? 'bg-slate-600'
: item.status === 'error'
? 'bg-red-500'
: 'bg-blue-500',
)}
style={{ width: `${item.progress}%` }}
/>
</div>
<div className="mt-2 text-[11px] text-slate-500">
{formatBytes(item.transferredBytes)} / {formatBytes(item.totalBytes)}
</div>
</div>
))}
</div>
</div>
</div>
) : null}
{conflictDialog.visible ? (
<Modal
title="文件冲突"
maxWidth="max-w-lg"
onClose={() => resolveConflictDecision('cancel', false)}
footer={
<>
<button
onClick={() => resolveConflictDecision('cancel', false)}
className="rounded bg-slate-700 px-4 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-600 hover:text-white"
>
</button>
<button
onClick={() => resolveConflictDecision('skip')}
className="rounded bg-slate-700 px-4 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-600 hover:text-white"
>
</button>
<button
onClick={() => resolveConflictDecision('overwrite')}
disabled={!conflictDialog.canOverwrite}
className={cn(
'rounded px-4 py-2 text-sm text-white transition-colors shadow-lg shadow-blue-500/20',
conflictDialog.canOverwrite
? 'bg-blue-600 hover:bg-blue-500'
: 'cursor-not-allowed bg-slate-700 text-slate-500 shadow-none',
)}
>
</button>
</>
}
>
<div className="flex items-start gap-3 text-slate-300">
<AlertCircle className="mt-0.5 shrink-0 text-yellow-500" size={24} />
<div>
<p className="mb-2">
<strong className="text-white">{conflictDialog.fileName}</strong>
{conflictDialog.fileType === 'dir' ? '文件夹' : '文件'}
</p>
<p className="text-sm text-slate-400">{conflictDialog.message}</p>
{conflictDialog.canOverwrite ? (
<p className="mt-2 text-sm text-slate-400"></p>
) : (
<p className="mt-2 text-sm text-slate-400"></p>
)}
<label className="group mt-5 flex w-max cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={applyToAll}
onChange={(event) => setApplyToAll(event.target.checked)}
className="cursor-pointer rounded border-slate-600 bg-slate-900 text-blue-500 transition-colors focus:ring-blue-500 focus:ring-offset-slate-800"
/>
<span className="text-sm text-slate-300 transition-colors group-hover:text-white"></span>
</label>
</div>
</div>
</Modal>
) : null}
<SftpCreateDirectoryModal
open={createDirectoryModalOpen}
currentPath={currentPath}
onClose={() => setCreateDirectoryModalOpen(false)}
onSubmit={handleCreateDir}
/>
</div> </div>
) )
} }
@@ -69,7 +69,7 @@ export default function TransferCenterModal({
onTasksChange([group, ...tasks]) onTasksChange([group, ...tasks])
targetIds.forEach(async (targetId) => { 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 const taskId = response.data.taskId
updateTaskGroup(groupId, (current) => ({ updateTaskGroup(groupId, (current) => ({
...current, ...current,
+14 -2
View File
@@ -28,8 +28,20 @@ export function formatTimestamp(timestamp?: number | null) {
return new Date(timestamp).toLocaleString() return new Date(timestamp).toLocaleString()
} }
export function formatSftpDate(epochSeconds: number) { export function formatSftpDate(timestampMs?: number | null) {
return new Date(epochSeconds * 1000).toLocaleString() 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 }) { export function formatSftpPermissions(entry: { directory: boolean }) {
+2 -2
View File
@@ -27,11 +27,11 @@ export async function downloadFile(connectionId: number, path: string, downloadN
URL.revokeObjectURL(url) 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() const form = new FormData()
form.append('file', file, file.name) form.append('file', file, file.name)
return http.post<{ taskId: string; message: string }>('/sftp/upload', form, { return http.post<{ taskId: string; message: string }>('/sftp/upload', form, {
params: { connectionId, path }, params: { connectionId, path, overwrite: options?.overwrite ?? false },
}) })
} }
+8
View File
@@ -78,6 +78,14 @@ export interface UploadTask {
finishedAt: number finishedAt: number
} }
export interface UploadConflictResponse {
code: 'SFTP_UPLOAD_CONFLICT'
fileName: string
conflictType: 'file' | 'dir'
canOverwrite: boolean
message: string
}
export interface RemoteTransferTask { export interface RemoteTransferTask {
taskId: string taskId: string
status: 'queued' | 'running' | 'success' | 'error' | 'cancelled' status: 'queued' | 'running' | 'success' | 'error' | 'cancelled'