feat: refine sftp pane upload workflow
This commit is contained in:
@@ -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<Map<String, Object>> 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<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")
|
||||
public ResponseEntity<Map<String, String>> 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<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 {
|
||||
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.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<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() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>) => 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 [entries, setEntries] = useState<SftpFileInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
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(
|
||||
() => 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,40 +227,308 @@ 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<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 (
|
||||
<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
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
void handleStartUpload(files)
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<button
|
||||
className="rounded p-1 text-slate-400 transition hover:bg-slate-700 hover:text-white"
|
||||
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"
|
||||
onClick={() => {
|
||||
if (currentPath === '/') return
|
||||
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="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={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="w-full bg-transparent outline-none"
|
||||
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-slate-500"
|
||||
value={currentPath}
|
||||
onChange={(event) => setCurrentPath(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -108,24 +536,146 @@ export default function SftpPane({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-1 flex gap-1 border-l border-slate-700 pl-2">
|
||||
<button className="rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-emerald-400">
|
||||
<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 className="ml-auto flex shrink-0 items-center gap-1.5 rounded-2xl border border-slate-700/80 bg-slate-950/70 p-1">
|
||||
<div className="relative" ref={uploadMenuRef}>
|
||||
<button
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
uploadMenuOpen || activeUploads > 0
|
||||
? toolbarActiveIconButtonClass
|
||||
: 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>
|
||||
<button className="rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-blue-400" onClick={handleCreateDir}>
|
||||
<Plus size={16} />
|
||||
{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 className="rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-white" onClick={() => void refresh()}>
|
||||
<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} />
|
||||
</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>
|
||||
|
||||
{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 ? (
|
||||
<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>
|
||||
<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} />
|
||||
下载
|
||||
</button>
|
||||
@@ -137,21 +687,73 @@ export default function SftpPane({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-left text-sm text-slate-300">
|
||||
<thead className="sticky top-0 bg-slate-800/95 text-xs text-slate-400">
|
||||
<div className="flex-1 overflow-auto isolate">
|
||||
<table className="min-w-[48rem] w-full text-left text-sm text-slate-300">
|
||||
<thead className="text-xs text-slate-400">
|
||||
<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
|
||||
type="checkbox"
|
||||
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 className="px-2 py-3">文件名</th>
|
||||
<th className="w-24 px-4 py-3">大小</th>
|
||||
<th className="w-40 px-4 py-3">修改时间</th>
|
||||
<th className="w-28 px-4 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')}>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -169,8 +771,31 @@ export default function SftpPane({
|
||||
</td>
|
||||
</tr>
|
||||
) : 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
|
||||
? entries.map((entry) => {
|
||||
? sortedEntries.map((entry) => {
|
||||
const checked = selectedFiles.includes(entry.name)
|
||||
return (
|
||||
<tr
|
||||
@@ -197,16 +822,28 @@ export default function SftpPane({
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</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">
|
||||
<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} />
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</tr>
|
||||
)
|
||||
@@ -215,6 +852,132 @@ export default function SftpPane({
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user