Compare commits
2 Commits
e2656fb1b5
...
acac45b692
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acac45b692 | ||
|
|
aced2871b2 |
@@ -0,0 +1,53 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class TerminalControlMessage {
|
||||
|
||||
public static final String CONTROL_PREFIX = "__SSHMANAGER__:";
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private String type;
|
||||
private Integer cols;
|
||||
private Integer rows;
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Integer getCols() {
|
||||
return cols;
|
||||
}
|
||||
|
||||
public void setCols(Integer cols) {
|
||||
this.cols = cols;
|
||||
}
|
||||
|
||||
public Integer getRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
public void setRows(Integer rows) {
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
public static Optional<TerminalControlMessage> parse(String payload) {
|
||||
if (payload == null || !payload.startsWith(CONTROL_PREFIX)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String json = payload.substring(CONTROL_PREFIX.length());
|
||||
try {
|
||||
return Optional.of(OBJECT_MAPPER.readValue(json, TerminalControlMessage.class));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,15 +100,26 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||
if (sshSession != null && sshSession.isConnected()) {
|
||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||
sshSession.getInputStream().write(message.asBytes());
|
||||
sshSession.getInputStream().flush();
|
||||
}
|
||||
}
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||
if (sshSession != null && sshSession.isConnected()) {
|
||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||
|
||||
String payload = message.getPayload();
|
||||
TerminalControlMessage.parse(payload).ifPresent(ctrl -> {
|
||||
if ("resize".equals(ctrl.getType()) && ctrl.getCols() != null && ctrl.getRows() != null) {
|
||||
sshSession.resize(ctrl.getCols(), ctrl.getRows());
|
||||
}
|
||||
});
|
||||
if (payload != null && payload.startsWith(TerminalControlMessage.CONTROL_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sshSession.getInputStream().write(message.asBytes());
|
||||
sshSession.getInputStream().flush();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
||||
|
||||
@@ -85,14 +85,27 @@ public class SshService {
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
public OutputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
public OutputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
public void resize(int cols, int rows) {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
return;
|
||||
}
|
||||
if (channel == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
channel.setPtySize(cols, rows, 0, 0);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
@@ -86,8 +87,8 @@ class SftpControllerTest {
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals("Transferred", response.getBody().get("message"));
|
||||
verify(sftpService).rename(session, "/src/file.txt", "/dst/file.txt");
|
||||
verify(sftpService, never()).transferRemote(any(), any(), any(), any());
|
||||
verify(sftpService).transferRemote(eq(session), eq("/src/file.txt"), eq(session), eq("/dst/file.txt"), any(SftpService.TransferProgressListener.class));
|
||||
verify(sftpService, never()).rename(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -109,7 +110,7 @@ class SftpControllerTest {
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals("Transferred", response.getBody().get("message"));
|
||||
verify(sftpService).transferRemote(sourceSession, "/src/file.txt", targetSession, "/dst/file.txt");
|
||||
verify(sftpService).transferRemote(eq(sourceSession), eq("/src/file.txt"), eq(targetSession), eq("/dst/file.txt"), any(SftpService.TransferProgressListener.class));
|
||||
verify(sftpService, never()).rename(any(), any(), any());
|
||||
}
|
||||
|
||||
@@ -118,7 +119,7 @@ class SftpControllerTest {
|
||||
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);
|
||||
doThrow(new RuntimeException("boom")).when(sftpService).rename(any(), any(), any());
|
||||
doThrow(new RuntimeException("boom")).when(sftpService).transferRemote(any(), anyString(), any(), anyString(), any(SftpService.TransferProgressListener.class));
|
||||
|
||||
ResponseEntity<Map<String, String>> response = sftpController.transferRemote(
|
||||
3L,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TerminalControlMessageTest {
|
||||
|
||||
@Test
|
||||
void parseReturnsResizeMessageWhenPayloadHasControlPrefix() {
|
||||
String payload = TerminalControlMessage.CONTROL_PREFIX
|
||||
+ "{\"type\":\"resize\",\"cols\":120,\"rows\":40}";
|
||||
|
||||
Optional<TerminalControlMessage> parsed = TerminalControlMessage.parse(payload);
|
||||
|
||||
assertTrue(parsed.isPresent());
|
||||
assertEquals("resize", parsed.get().getType());
|
||||
assertEquals(120, parsed.get().getCols());
|
||||
assertEquals(40, parsed.get().getRows());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseReturnsEmptyForNonControlPayload() {
|
||||
Optional<TerminalControlMessage> parsed = TerminalControlMessage.parse("ls -la\n");
|
||||
assertFalse(parsed.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseReturnsEmptyForInvalidJson() {
|
||||
String payload = TerminalControlMessage.CONTROL_PREFIX + "not-json";
|
||||
Optional<TerminalControlMessage> parsed = TerminalControlMessage.parse(payload);
|
||||
assertFalse(parsed.isPresent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SshService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TerminalWebSocketHandlerTest {
|
||||
|
||||
@Test
|
||||
void handleTextMessageAppliesResizeControlMessageWithoutForwardingToShell() throws Exception {
|
||||
ConnectionRepository connectionRepository = mock(ConnectionRepository.class);
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
ConnectionService connectionService = mock(ConnectionService.class);
|
||||
SshService sshService = mock(SshService.class);
|
||||
ExecutorService executor = mock(ExecutorService.class);
|
||||
|
||||
TerminalWebSocketHandler handler = new TerminalWebSocketHandler(
|
||||
connectionRepository,
|
||||
userRepository,
|
||||
connectionService,
|
||||
sshService,
|
||||
executor
|
||||
);
|
||||
|
||||
WebSocketSession ws = mock(WebSocketSession.class);
|
||||
when(ws.getId()).thenReturn("s1");
|
||||
|
||||
SshService.SshSession sshSession = mock(SshService.SshSession.class);
|
||||
when(sshSession.isConnected()).thenReturn(true);
|
||||
Map<String, SshService.SshSession> sessions = sessionsMap(handler);
|
||||
sessions.put("s1", sshSession);
|
||||
|
||||
String payload = TerminalControlMessage.CONTROL_PREFIX
|
||||
+ "{\"type\":\"resize\",\"cols\":120,\"rows\":40}";
|
||||
handler.handleTextMessage(ws, new TextMessage(payload));
|
||||
|
||||
verify(sshSession).resize(120, 40);
|
||||
verify(sshSession, never()).getInputStream();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, SshService.SshSession> sessionsMap(TerminalWebSocketHandler handler) throws Exception {
|
||||
Field f = TerminalWebSocketHandler.class.getDeclaredField("sessions");
|
||||
f.setAccessible(true);
|
||||
return (Map<String, SshService.SshSession>) f.get(handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelShell;
|
||||
import com.jcraft.jsch.Session;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class SshSessionResizeTest {
|
||||
|
||||
@Test
|
||||
void resizeSetsPtySizeOnChannel() {
|
||||
Session session = mock(Session.class);
|
||||
ChannelShell channel = mock(ChannelShell.class);
|
||||
InputStream out = mock(InputStream.class);
|
||||
OutputStream in = mock(OutputStream.class);
|
||||
|
||||
SshService.SshSession sshSession = new SshService.SshSession(session, channel, out, in);
|
||||
sshSession.resize(120, 40);
|
||||
|
||||
verify(channel).setPtySize(120, 40, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,19 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import * as sftpApi from '../api/sftp'
|
||||
import type { SftpFileInfo } from '../api/sftp'
|
||||
import { X, FolderOpen, File, ChevronRight, RefreshCw, Eye, EyeOff } from 'lucide-vue-next'
|
||||
import { X, FolderOpen, File, ChevronRight, RefreshCw, Eye, EyeOff, Check } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{ open: boolean; connectionId: number | null }>()
|
||||
const props = withDefaults(defineProps<{ open: boolean; connectionId: number | null; multiple?: boolean }>(), {
|
||||
multiple: false
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'select', path: string): void
|
||||
(e: 'select-many', paths: string[]): void
|
||||
}>()
|
||||
|
||||
const currentPath = ref('.')
|
||||
const pathParts = ref<string[]>([])
|
||||
const currentPath = ref('/')
|
||||
const pathParts = computed(() => (currentPath.value === '/' ? [] : currentPath.value.split('/').filter(Boolean)))
|
||||
const files = ref<SftpFileInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -20,34 +23,62 @@ const error = ref('')
|
||||
const showHiddenFiles = ref(false)
|
||||
const searchQuery = ref('')
|
||||
let searchDebounceTimer = 0
|
||||
let suppressNextSearchDebounce = false
|
||||
const filteredFiles = ref<SftpFileInfo[]>([])
|
||||
|
||||
const selectedPaths = ref<string[]>([])
|
||||
const selectedPathSet = computed(() => new Set(selectedPaths.value))
|
||||
const selectedCount = computed(() => selectedPaths.value.length)
|
||||
|
||||
const canInteract = computed(() => props.open && props.connectionId != null)
|
||||
|
||||
function applyFileFilters() {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
|
||||
const sanitized = files.value.filter((f) => f.name !== '.' && f.name !== '..')
|
||||
const base = showHiddenFiles.value ? sanitized : sanitized.filter((f) => !f.name.startsWith('.'))
|
||||
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
|
||||
}
|
||||
|
||||
watch([searchQuery, showHiddenFiles, files], () => {
|
||||
watch(searchQuery, () => {
|
||||
if (suppressNextSearchDebounce) {
|
||||
suppressNextSearchDebounce = false
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = 0
|
||||
return
|
||||
}
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
applyFileFilters()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
watch([files, showHiddenFiles], () => {
|
||||
applyFileFilters()
|
||||
}, { immediate: true })
|
||||
|
||||
function normalizeServerPath(input: string) {
|
||||
const raw = (input || '').trim()
|
||||
if (!raw || raw === '.') return '/'
|
||||
if (raw === '/') return '/'
|
||||
const abs = raw.startsWith('/') ? raw : '/' + raw
|
||||
return abs.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function joinAbsolutePath(base: string, name: string) {
|
||||
const safeBase = !base || base === '.' ? '/' : base
|
||||
const baseClean = safeBase === '/' ? '' : safeBase.replace(/\/+$/, '')
|
||||
const nameClean = (name || '').replace(/^\/+/, '')
|
||||
return (baseClean ? baseClean + '/' : '/') + nameClean
|
||||
}
|
||||
|
||||
async function initPath() {
|
||||
if (!canInteract.value || props.connectionId == null) return
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await sftpApi.getPwd(props.connectionId)
|
||||
const p = res.data.path || '/'
|
||||
currentPath.value = p === '/' ? '/' : p
|
||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||
currentPath.value = normalizeServerPath(res.data.path || '/')
|
||||
} catch (e: unknown) {
|
||||
currentPath.value = '.'
|
||||
pathParts.value = []
|
||||
currentPath.value = '/'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +86,15 @@ async function load() {
|
||||
if (!canInteract.value || props.connectionId == null) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = 0
|
||||
suppressNextSearchDebounce = true
|
||||
searchQuery.value = ''
|
||||
applyFileFilters()
|
||||
try {
|
||||
const res = await sftpApi.listFiles(props.connectionId, currentPath.value)
|
||||
files.value = res.data
|
||||
.filter((f) => f.name !== '.' && f.name !== '..')
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||
@@ -74,26 +110,49 @@ async function load() {
|
||||
|
||||
function navigateToDir(name: string) {
|
||||
if (loading.value) return
|
||||
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
||||
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||
currentPath.value = joinAbsolutePath(currentPath.value, name)
|
||||
load()
|
||||
}
|
||||
|
||||
function navigateToIndex(i: number) {
|
||||
if (loading.value) return
|
||||
if (i < 0) {
|
||||
currentPath.value = '.'
|
||||
currentPath.value = '/'
|
||||
} else {
|
||||
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
||||
const next = pathParts.value.slice(0, i + 1).join('/')
|
||||
currentPath.value = next ? '/' + next : '/'
|
||||
}
|
||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||
load()
|
||||
}
|
||||
|
||||
function filePath(file: SftpFileInfo) {
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
return base ? base.replace(/\/$/, '') + '/' + file.name : file.name
|
||||
return joinAbsolutePath(currentPath.value, file.name)
|
||||
}
|
||||
|
||||
function isSelected(path: string) {
|
||||
return selectedPathSet.value.has(path)
|
||||
}
|
||||
|
||||
function toggleSelected(path: string) {
|
||||
if (isSelected(path)) {
|
||||
selectedPaths.value = selectedPaths.value.filter((p) => p !== path)
|
||||
return
|
||||
}
|
||||
selectedPaths.value = [...selectedPaths.value, path]
|
||||
}
|
||||
|
||||
function removeSelected(path: string) {
|
||||
if (!isSelected(path)) return
|
||||
selectedPaths.value = selectedPaths.value.filter((p) => p !== path)
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
selectedPaths.value = []
|
||||
}
|
||||
|
||||
function displayPathName(path: string) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
function handleClick(file: SftpFileInfo) {
|
||||
@@ -101,7 +160,30 @@ function handleClick(file: SftpFileInfo) {
|
||||
navigateToDir(file.name)
|
||||
return
|
||||
}
|
||||
emit('select', filePath(file))
|
||||
|
||||
const path = filePath(file)
|
||||
if (props.multiple) {
|
||||
toggleSelected(path)
|
||||
return
|
||||
}
|
||||
|
||||
emit('select', path)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleRowKeyDown(e: KeyboardEvent, file: SftpFileInfo) {
|
||||
const isEnter = e.key === 'Enter'
|
||||
const isSpace = e.key === ' ' || e.code === 'Space'
|
||||
if (!isEnter && !isSpace) return
|
||||
e.preventDefault()
|
||||
if (isSpace && e.repeat) return
|
||||
handleClick(file)
|
||||
}
|
||||
|
||||
function confirmMany() {
|
||||
if (!props.multiple) return
|
||||
if (selectedPaths.value.length === 0) return
|
||||
emit('select-many', selectedPaths.value)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -109,6 +191,7 @@ watch(
|
||||
() => [props.open, props.connectionId] as const,
|
||||
async ([open]) => {
|
||||
if (!open) return
|
||||
selectedPaths.value = []
|
||||
await initPath()
|
||||
await load()
|
||||
}
|
||||
@@ -133,7 +216,8 @@ onBeforeUnmount(() => {
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-slate-100 font-semibold truncate">选择源文件</h3>
|
||||
<p class="text-xs text-slate-400 truncate">双击文件不需要,单击即选择</p>
|
||||
<p v-if="!props.multiple" class="text-xs text-slate-400 truncate">双击文件不需要,单击即选择</p>
|
||||
<p v-else class="text-xs text-slate-400 truncate">单击文件切换选择;目录仅用于导航</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@@ -141,6 +225,7 @@ onBeforeUnmount(() => {
|
||||
:disabled="loading || !canInteract"
|
||||
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 disabled:opacity-50 cursor-pointer transition-colors"
|
||||
aria-label="刷新"
|
||||
type="button"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||
@@ -151,6 +236,7 @@ onBeforeUnmount(() => {
|
||||
@click="emit('close')"
|
||||
class="min-h-[44px] w-11 grid place-items-center rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 cursor-pointer transition-colors"
|
||||
aria-label="关闭"
|
||||
type="button"
|
||||
>
|
||||
<X class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -162,6 +248,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
@click="navigateToIndex(-1)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate"
|
||||
type="button"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
@@ -170,6 +257,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
@click="navigateToIndex(i)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate max-w-[140px]"
|
||||
type="button"
|
||||
>
|
||||
{{ part || '/' }}
|
||||
</button>
|
||||
@@ -179,6 +267,8 @@ onBeforeUnmount(() => {
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
name="sftp-file-search"
|
||||
autocomplete="off"
|
||||
class="flex-1 rounded-lg border border-slate-600 bg-slate-900/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="搜索文件..."
|
||||
aria-label="搜索文件"
|
||||
@@ -188,21 +278,71 @@ onBeforeUnmount(() => {
|
||||
class="min-h-[44px] p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||
type="button"
|
||||
>
|
||||
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="mt-2 text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<div v-if="props.multiple" class="mt-3 rounded-xl border border-slate-700 bg-slate-900/30 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-slate-200">Selected ({{ selectedCount }})</div>
|
||||
<button
|
||||
v-if="selectedCount > 0"
|
||||
@click="clearSelected"
|
||||
class="text-xs text-slate-400 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedCount === 0" class="mt-1 text-xs text-slate-500">未选择任何文件</div>
|
||||
<div v-else class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="path in selectedPaths"
|
||||
:key="path"
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-slate-700 bg-slate-800/40 px-2 py-1 text-xs text-slate-200"
|
||||
:title="path"
|
||||
>
|
||||
<span class="max-w-[220px] truncate">{{ displayPathName(path) }}</span>
|
||||
<button
|
||||
class="w-6 h-6 grid place-items-center rounded hover:bg-slate-700/60 text-slate-300 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
:aria-label="'移除 ' + displayPathName(path)"
|
||||
@click="removeSelected(path)"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-auto divide-y divide-slate-800">
|
||||
<button
|
||||
<div
|
||||
v-for="file in filteredFiles"
|
||||
:key="file.name"
|
||||
@click="handleClick(file)"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-800/40 transition-colors cursor-pointer min-h-[44px]"
|
||||
:aria-label="file.directory ? '打开目录' : '选择文件'"
|
||||
@keydown="handleRowKeyDown($event, file)"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-800/40 transition-colors cursor-pointer min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/80"
|
||||
:role="props.multiple && !file.directory ? 'checkbox' : 'button'"
|
||||
:tabindex="0"
|
||||
:aria-checked="props.multiple && !file.directory ? isSelected(filePath(file)) : undefined"
|
||||
:aria-label="file.directory
|
||||
? '打开目录 ' + file.name
|
||||
: (props.multiple
|
||||
? ((isSelected(filePath(file)) ? '取消选择文件 ' : '选择文件 ') + file.name)
|
||||
: ('选择文件 ' + file.name))"
|
||||
>
|
||||
<span v-if="props.multiple && !file.directory" class="flex-shrink-0" aria-hidden="true">
|
||||
<span
|
||||
class="h-4 w-4 rounded border grid place-items-center transition-colors"
|
||||
:class="isSelected(filePath(file)) ? 'border-cyan-400 bg-cyan-500' : 'border-slate-500 bg-slate-900/40'"
|
||||
>
|
||||
<Check v-if="isSelected(filePath(file))" class="w-3 h-3 text-slate-950" aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
<component
|
||||
:is="file.directory ? FolderOpen : File"
|
||||
class="w-5 h-5 flex-shrink-0"
|
||||
@@ -211,12 +351,30 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<span class="flex-1 min-w-0 truncate text-slate-100">{{ file.name }}</span>
|
||||
<span v-if="!file.directory" class="text-xs text-slate-500">{{ Math.round(file.size / 1024) }} KB</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredFiles.length === 0 && !loading" class="px-4 py-10 text-center text-slate-500">
|
||||
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.multiple" class="flex items-center justify-end gap-2 px-4 py-3 border-t border-slate-700 bg-slate-900/40">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="min-h-[44px] px-4 rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmMany"
|
||||
:disabled="selectedCount === 0"
|
||||
class="min-h-[44px] px-4 rounded-lg bg-cyan-600/90 text-slate-950 hover:bg-cyan-500 disabled:opacity-50 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
Confirm ({{ selectedCount }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Terminal } from 'xterm'
|
||||
import { AttachAddon } from '@xterm/addon-attach'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: number
|
||||
active?: boolean
|
||||
@@ -15,10 +16,15 @@ const containerRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
|
||||
const errorMessage = ref('')
|
||||
|
||||
let term: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let term: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let onDataDisposable: { dispose: () => void } | null = null
|
||||
let onResizeDisposable: { dispose: () => void } | null = null
|
||||
let resizeDebounceTimer = 0
|
||||
let lastSentCols = 0
|
||||
let lastSentRows = 0
|
||||
|
||||
function fitTerminal() {
|
||||
fitAddon?.fit()
|
||||
@@ -46,21 +52,67 @@ function getWsUrl(): string {
|
||||
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (resizeObserver && containerRef.value) {
|
||||
resizeObserver.unobserve(containerRef.value)
|
||||
}
|
||||
resizeObserver = null
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (term) {
|
||||
term.dispose()
|
||||
term = null
|
||||
}
|
||||
fitAddon = null
|
||||
}
|
||||
function cleanup() {
|
||||
if (resizeObserver && containerRef.value) {
|
||||
resizeObserver.unobserve(containerRef.value)
|
||||
}
|
||||
resizeObserver = null
|
||||
|
||||
if (onDataDisposable) {
|
||||
onDataDisposable.dispose()
|
||||
onDataDisposable = null
|
||||
}
|
||||
if (onResizeDisposable) {
|
||||
onResizeDisposable.dispose()
|
||||
onResizeDisposable = null
|
||||
}
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeDebounceTimer = 0
|
||||
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (term) {
|
||||
term.dispose()
|
||||
term = null
|
||||
}
|
||||
fitAddon = null
|
||||
}
|
||||
|
||||
function sendResize(cols: number, rows: number) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
if (!cols || !rows) return
|
||||
if (cols === lastSentCols && rows === lastSentRows) return
|
||||
lastSentCols = cols
|
||||
lastSentRows = rows
|
||||
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
|
||||
function scheduleResize(cols: number, rows: number) {
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeDebounceTimer = window.setTimeout(() => {
|
||||
sendResize(cols, rows)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
async function handleWsMessage(data: unknown) {
|
||||
if (!term) return
|
||||
|
||||
if (typeof data === 'string') {
|
||||
term.write(data)
|
||||
return
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const text = new TextDecoder('utf-8').decode(new Uint8Array(data))
|
||||
term.write(text)
|
||||
return
|
||||
}
|
||||
if (data instanceof Blob) {
|
||||
const text = await data.text()
|
||||
term.write(text)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const authStore = useAuthStore()
|
||||
@@ -72,7 +124,7 @@ onMounted(() => {
|
||||
|
||||
if (!containerRef.value) return
|
||||
|
||||
term = new Terminal({
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: '#0f172a',
|
||||
@@ -85,10 +137,14 @@ onMounted(() => {
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(containerRef.value)
|
||||
fitTerminal()
|
||||
|
||||
onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
scheduleResize(cols, rows)
|
||||
})
|
||||
|
||||
try {
|
||||
ws = new WebSocket(getWsUrl())
|
||||
@@ -98,11 +154,19 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.onopen = () => {
|
||||
status.value = 'connected'
|
||||
const attachAddon = new AttachAddon(ws!)
|
||||
term!.loadAddon(attachAddon)
|
||||
refreshTerminalLayout()
|
||||
scheduleResize(term!.cols, term!.rows)
|
||||
}
|
||||
|
||||
onDataDisposable = term.onData((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(data)
|
||||
})
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
void handleWsMessage(ev.data)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
@@ -120,6 +184,9 @@ onMounted(() => {
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitTerminal()
|
||||
if (term) {
|
||||
scheduleResize(term.cols, term.rows)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
|
||||
|
||||
@@ -73,9 +73,27 @@ async function runWithConcurrency<T>(
|
||||
await Promise.allSettled(workers)
|
||||
}
|
||||
|
||||
async function waitForRemoteTransfer(taskId: string, onProgress: (progress: number) => void, unsubscribers: (() => void)[]) {
|
||||
async function waitForRemoteTransfer(
|
||||
taskId: string,
|
||||
onProgress: (progress: number) => void,
|
||||
unsubscribers: (() => void)[]
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
console.log('[waitForRemoteTransfer] Subscribing to task:', taskId)
|
||||
let done = false
|
||||
|
||||
const finish = (err?: Error) => {
|
||||
if (done) return
|
||||
done = true
|
||||
try {
|
||||
unsubscribe()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeRemoteTransferProgress(taskId, (task) => {
|
||||
const progress = Math.max(0, Math.min(100, task.progress || 0))
|
||||
console.log('[waitForRemoteTransfer] Progress from SSE:', progress, 'status:', task.status)
|
||||
@@ -83,25 +101,51 @@ async function waitForRemoteTransfer(taskId: string, onProgress: (progress: numb
|
||||
|
||||
if (task.status === 'success') {
|
||||
console.log('[waitForRemoteTransfer] Task succeeded:', taskId)
|
||||
resolve()
|
||||
finish()
|
||||
} else if (task.status === 'error') {
|
||||
console.error('[waitForRemoteTransfer] Task errored:', taskId, task.error)
|
||||
reject(new Error(task.error || 'Transfer failed'))
|
||||
finish(new Error(task.error || 'Transfer failed'))
|
||||
} else if (task.status === 'cancelled') {
|
||||
console.log('[waitForRemoteTransfer] Task cancelled:', taskId)
|
||||
reject(new Error('Cancelled'))
|
||||
finish(new Error('Cancelled'))
|
||||
}
|
||||
})
|
||||
|
||||
unsubscribers.push(unsubscribe)
|
||||
// cancelRun/clearRuns will call this; it must close SSE and release the waiter.
|
||||
unsubscribers.push(() => {
|
||||
finish(new Error('Cancelled'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildRemoteTransferPath(targetDir: string, filename: string) {
|
||||
let targetPath = targetDir.trim()
|
||||
if (!targetPath) targetPath = '/'
|
||||
if (!targetPath.endsWith('/')) targetPath = targetPath + '/'
|
||||
return targetPath + filename
|
||||
function basename(p: string) {
|
||||
return p.split('/').filter(Boolean).pop() || p
|
||||
}
|
||||
|
||||
function normalizeDir(p: string) {
|
||||
let dir = p.trim()
|
||||
if (!dir) dir = '/'
|
||||
if (!dir.endsWith('/')) dir = dir + '/'
|
||||
return dir
|
||||
}
|
||||
|
||||
function resolveRemoteTargetPath(params: {
|
||||
targetMode: 'dir' | 'path'
|
||||
targetDirOrPath: string
|
||||
sourcePath: string
|
||||
sourceCount: number
|
||||
}) {
|
||||
if (params.targetMode === 'path') {
|
||||
if (params.sourceCount !== 1) {
|
||||
throw new Error('Exact Path mode requires a single source file')
|
||||
}
|
||||
const t = params.targetDirOrPath.trim()
|
||||
if (!t) throw new Error('target path is required')
|
||||
return t
|
||||
}
|
||||
|
||||
const dir = normalizeDir(params.targetDirOrPath)
|
||||
return dir + basename(params.sourcePath)
|
||||
}
|
||||
|
||||
export const useTransfersStore = defineStore('transfers', () => {
|
||||
@@ -219,18 +263,35 @@ export const useTransfersStore = defineStore('transfers', () => {
|
||||
|
||||
// 订阅上传任务进度,等待真正完成
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false
|
||||
const finish = (err?: Error) => {
|
||||
if (done) return
|
||||
done = true
|
||||
try {
|
||||
unsubscribe()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeUploadProgress(taskId, (task) => {
|
||||
const progress = Math.max(0, Math.min(100, task.progress || 0))
|
||||
item.progress = progress
|
||||
runs.value = [...runs.value]
|
||||
|
||||
if (task.status === 'success') {
|
||||
resolve()
|
||||
finish()
|
||||
} else if (task.status === 'error') {
|
||||
reject(new Error(task.error || 'Upload failed'))
|
||||
finish(new Error(task.error || 'Upload failed'))
|
||||
}
|
||||
})
|
||||
unsubscribers.push(unsubscribe)
|
||||
|
||||
// cancelRun/clearRuns will call this; close SSE + release waiter.
|
||||
unsubscribers.push(() => {
|
||||
finish(new Error('Cancelled'))
|
||||
})
|
||||
})
|
||||
|
||||
item.status = 'success'
|
||||
@@ -263,29 +324,33 @@ export const useTransfersStore = defineStore('transfers', () => {
|
||||
|
||||
async function startRemoteToMany(params: {
|
||||
sourceConnectionId: number
|
||||
sourcePath: string
|
||||
sourcePaths: string[]
|
||||
targetConnectionIds: number[]
|
||||
targetDirOrPath: string
|
||||
targetMode: 'dir' | 'path'
|
||||
concurrency?: number
|
||||
}) {
|
||||
const { sourceConnectionId, sourcePath, targetConnectionIds, targetDirOrPath } = params
|
||||
const { sourceConnectionId, sourcePaths, targetConnectionIds, targetDirOrPath, targetMode } = params
|
||||
const concurrency = params.concurrency ?? 3
|
||||
|
||||
if (sourceConnectionId == null) return
|
||||
|
||||
const sources = sourcePaths.map((p) => p.trim()).filter(Boolean)
|
||||
if (sources.length === 0) return
|
||||
|
||||
const runId = uid('run')
|
||||
const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath
|
||||
const runItems: TransferItem[] = targetConnectionIds.map((targetId) => ({
|
||||
id: uid('item'),
|
||||
label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`,
|
||||
status: 'queued' as const,
|
||||
progress: 0,
|
||||
}))
|
||||
const onlySource = sources[0]
|
||||
const title = sources.length === 1 && onlySource
|
||||
? `Remote ${basename(onlySource)} -> ${targetConnectionIds.length} targets`
|
||||
: `Remote ${sources.length} files -> ${targetConnectionIds.length} targets`
|
||||
|
||||
const runItems: TransferItem[] = []
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
|
||||
const run: TransferRun = {
|
||||
id: runId,
|
||||
mode: 'REMOTE_TO_MANY' as const,
|
||||
title: `Remote ${filename} -> ${targetConnectionIds.length} targets`,
|
||||
title,
|
||||
createdAt: now(),
|
||||
items: runItems,
|
||||
status: 'queued' as const,
|
||||
@@ -302,66 +367,82 @@ export const useTransfersStore = defineStore('transfers', () => {
|
||||
unsubscribers,
|
||||
})
|
||||
|
||||
const tasks = runItems.map((item, index) => {
|
||||
return async () => {
|
||||
const targetId = targetConnectionIds[index]
|
||||
if (targetId == null) {
|
||||
item.status = 'error'
|
||||
item.progress = 100
|
||||
item.message = 'Missing target connection'
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
return
|
||||
for (const sourcePath of sources) {
|
||||
for (const targetId of targetConnectionIds) {
|
||||
const item: TransferItem = {
|
||||
id: uid('item'),
|
||||
label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`,
|
||||
status: 'queued' as const,
|
||||
progress: 0,
|
||||
}
|
||||
if (cancelled) {
|
||||
item.status = 'cancelled'
|
||||
item.progress = 100
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
return
|
||||
}
|
||||
item.status = 'running'
|
||||
item.progress = 0
|
||||
item.startedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
console.log('[Remote->Many] Starting transfer:', item.label, 'targetId:', targetId)
|
||||
try {
|
||||
const targetPath = buildRemoteTransferPath(targetDirOrPath, filename)
|
||||
console.log('[Remote->Many] Target path:', targetPath)
|
||||
runItems.push(item)
|
||||
|
||||
const task = await createRemoteTransferTask(sourceConnectionId, sourcePath, targetId, targetPath)
|
||||
const taskId = task.data.taskId
|
||||
console.log('[Remote->Many] Task created:', taskId)
|
||||
await waitForRemoteTransfer(taskId, (progress) => {
|
||||
console.log('[Remote->Many] Progress update:', progress, 'item:', item.label)
|
||||
item.progress = Math.max(item.progress || 0, progress)
|
||||
runs.value = [...runs.value]
|
||||
}, unsubscribers)
|
||||
|
||||
item.status = 'success'
|
||||
item.progress = 100
|
||||
item.finishedAt = now()
|
||||
console.log('[Remote->Many] Transfer completed:', item.label)
|
||||
runs.value = [...runs.value]
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
const msg = err?.response?.data?.error || (e as Error)?.message || 'Transfer failed'
|
||||
console.error('[Remote->Many] Transfer failed:', item.label, 'error:', msg)
|
||||
if (msg === 'Cancelled') {
|
||||
tasks.push(async () => {
|
||||
if (cancelled) {
|
||||
item.status = 'cancelled'
|
||||
item.progress = 100
|
||||
} else {
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
return
|
||||
}
|
||||
|
||||
item.status = 'running'
|
||||
item.progress = 0
|
||||
item.startedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
console.log('[Remote->Many] Starting transfer:', item.label, 'targetId:', targetId)
|
||||
|
||||
let targetPath = ''
|
||||
try {
|
||||
targetPath = resolveRemoteTargetPath({
|
||||
targetMode,
|
||||
targetDirOrPath,
|
||||
sourcePath,
|
||||
sourceCount: sources.length,
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
item.status = 'error'
|
||||
item.progress = 100
|
||||
item.message = msg
|
||||
item.message = (e as Error)?.message || 'Invalid target path'
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
return
|
||||
}
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
} finally {
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
|
||||
console.log('[Remote->Many] Target path:', targetPath)
|
||||
|
||||
try {
|
||||
const task = await createRemoteTransferTask(sourceConnectionId, sourcePath, targetId, targetPath)
|
||||
const taskId = task.data.taskId
|
||||
console.log('[Remote->Many] Task created:', taskId)
|
||||
await waitForRemoteTransfer(taskId, (progress) => {
|
||||
item.progress = Math.max(item.progress || 0, progress)
|
||||
runs.value = [...runs.value]
|
||||
}, unsubscribers)
|
||||
|
||||
item.status = 'success'
|
||||
item.progress = 100
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
const msg = err?.response?.data?.error || (e as Error)?.message || 'Transfer failed'
|
||||
if (msg === 'Cancelled') {
|
||||
item.status = 'cancelled'
|
||||
item.progress = 100
|
||||
} else {
|
||||
item.status = 'error'
|
||||
item.progress = 100
|
||||
item.message = msg
|
||||
}
|
||||
item.finishedAt = now()
|
||||
runs.value = [...runs.value]
|
||||
} finally {
|
||||
runs.value = [...runs.value]
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await runWithConcurrency(tasks, concurrency)
|
||||
runs.value = [...runs.value]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useTransfersStore } from '../stores/transfers'
|
||||
@@ -32,11 +32,15 @@ const localConcurrency = ref(3)
|
||||
|
||||
// Remote -> many
|
||||
const remoteSourceConnectionId = ref<number | null>(null)
|
||||
const remoteSourcePath = ref('')
|
||||
const remoteSourceDraft = ref('')
|
||||
const remoteSourcePaths = ref<string[]>([])
|
||||
const remoteTargetDirOrPath = ref('/')
|
||||
const remoteTargetMode = ref<'dir' | 'path'>('dir')
|
||||
const remoteSelectedTargets = ref<number[]>([])
|
||||
const remoteConcurrency = ref(3)
|
||||
|
||||
const remoteBasenameCollisionAck = ref(false)
|
||||
|
||||
// Picker
|
||||
const pickerOpen = ref(false)
|
||||
|
||||
@@ -49,9 +53,77 @@ const remoteTargetConnectionOptions = computed(() =>
|
||||
)
|
||||
|
||||
const canStartLocal = computed(() => localFiles.value.length > 0 && localSelectedTargets.value.length > 0)
|
||||
const canStartRemote = computed(
|
||||
() => remoteSourceConnectionId.value != null && remoteSourcePath.value.trim() && remoteSelectedTargets.value.length > 0
|
||||
)
|
||||
|
||||
function basename(p: string) {
|
||||
return p.split('/').filter(Boolean).pop() || p
|
||||
}
|
||||
|
||||
function mergeRemoteSourcePaths(paths: string[]) {
|
||||
const merged = remoteSourcePaths.value.slice()
|
||||
for (const raw of paths) {
|
||||
const p = raw.trim()
|
||||
if (!p) continue
|
||||
if (!merged.includes(p)) merged.push(p)
|
||||
}
|
||||
remoteSourcePaths.value = merged
|
||||
}
|
||||
|
||||
function addRemoteSourceDraft() {
|
||||
if (!remoteSourceDraft.value.trim()) return
|
||||
mergeRemoteSourcePaths([remoteSourceDraft.value])
|
||||
remoteSourceDraft.value = ''
|
||||
}
|
||||
|
||||
function removeRemoteSourcePath(path: string) {
|
||||
remoteSourcePaths.value = remoteSourcePaths.value.filter((p) => p !== path)
|
||||
}
|
||||
|
||||
function clearRemoteSources() {
|
||||
remoteSourcePaths.value = []
|
||||
remoteBasenameCollisionAck.value = false
|
||||
}
|
||||
|
||||
const remoteHasBasenameCollision = computed(() => {
|
||||
if (remoteTargetMode.value !== 'dir') return false
|
||||
const counts = new Map<string, number>()
|
||||
for (const p of remoteSourcePaths.value) {
|
||||
const name = basename(p)
|
||||
counts.set(name, (counts.get(name) || 0) + 1)
|
||||
}
|
||||
for (const n of counts.values()) {
|
||||
if (n > 1) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const remoteValidationError = computed(() => {
|
||||
if (remoteSourceConnectionId.value == null) return ''
|
||||
|
||||
if (remoteSourcePaths.value.length === 0) return '请先选择至少 1 个源文件'
|
||||
|
||||
for (const p of remoteSourcePaths.value) {
|
||||
const clean = p.trim()
|
||||
if (!clean) return '源文件路径不能为空'
|
||||
if (clean.endsWith('/')) return '源文件路径必须是文件(不能以 / 结尾)'
|
||||
}
|
||||
|
||||
if (remoteTargetMode.value === 'path') {
|
||||
if (remoteSourcePaths.value.length !== 1) return 'Exact Path 只支持单文件'
|
||||
const t = remoteTargetDirOrPath.value.trim()
|
||||
if (!t) return 'Exact Path 需要填写目标文件路径'
|
||||
if (t.endsWith('/')) return 'Exact Path 不能以 / 结尾'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const canStartRemote = computed(() => {
|
||||
if (remoteSourceConnectionId.value == null) return false
|
||||
if (remoteSelectedTargets.value.length === 0) return false
|
||||
if (remoteValidationError.value) return false
|
||||
if (remoteHasBasenameCollision.value && !remoteBasenameCollisionAck.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function onLocalFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
@@ -129,9 +201,10 @@ async function startRemote() {
|
||||
if (!canStartRemote.value || remoteSourceConnectionId.value == null) return
|
||||
await transfersStore.startRemoteToMany({
|
||||
sourceConnectionId: remoteSourceConnectionId.value,
|
||||
sourcePath: remoteSourcePath.value.trim(),
|
||||
sourcePaths: remoteSourcePaths.value,
|
||||
targetConnectionIds: remoteSelectedTargets.value,
|
||||
targetDirOrPath: remoteTargetDirOrPath.value,
|
||||
targetMode: remoteTargetMode.value,
|
||||
concurrency: remoteConcurrency.value,
|
||||
})
|
||||
}
|
||||
@@ -141,6 +214,27 @@ function openPicker() {
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
watch(remoteSourceConnectionId, (next, prev) => {
|
||||
if (next === prev) return
|
||||
remoteSourceDraft.value = ''
|
||||
remoteSourcePaths.value = []
|
||||
remoteBasenameCollisionAck.value = false
|
||||
remoteTargetMode.value = 'dir'
|
||||
if (next != null) {
|
||||
remoteSelectedTargets.value = remoteSelectedTargets.value.filter((id) => id !== next)
|
||||
}
|
||||
})
|
||||
|
||||
watch(remoteSourcePaths, (paths) => {
|
||||
if (paths.length > 1 && remoteTargetMode.value === 'path') {
|
||||
remoteTargetMode.value = 'dir'
|
||||
}
|
||||
})
|
||||
|
||||
watch(remoteHasBasenameCollision, (hasCollision) => {
|
||||
if (!hasCollision) remoteBasenameCollisionAck.value = false
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
await connectionsStore.fetchConnections().catch(() => {})
|
||||
@@ -326,39 +420,128 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label for="remote-source-path" class="text-sm text-slate-300">源文件路径</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="remote-source-path"
|
||||
v-model="remoteSourcePath"
|
||||
type="text"
|
||||
placeholder="/path/to/file"
|
||||
class="flex-1 rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
<button
|
||||
@click="openPicker"
|
||||
:disabled="remoteSourceConnectionId == null"
|
||||
class="min-h-[44px] px-3 rounded-xl border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 disabled:opacity-50 cursor-pointer transition-colors"
|
||||
aria-label="浏览远程文件"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
浏览
|
||||
<label for="remote-source-draft" class="text-sm text-slate-300">源文件</label>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="remote-source-draft"
|
||||
v-model="remoteSourceDraft"
|
||||
type="text"
|
||||
placeholder="/path/to/file"
|
||||
class="flex-1 rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
@keydown.enter.prevent="addRemoteSourceDraft"
|
||||
/>
|
||||
|
||||
<button
|
||||
@click="addRemoteSourceDraft"
|
||||
:disabled="!remoteSourceDraft.trim()"
|
||||
class="min-h-[44px] px-3 rounded-xl border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 disabled:opacity-50 cursor-pointer transition-colors"
|
||||
type="button"
|
||||
aria-label="添加源文件路径"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="openPicker"
|
||||
:disabled="remoteSourceConnectionId == null"
|
||||
class="min-h-[44px] px-3 rounded-xl border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 disabled:opacity-50 cursor-pointer transition-colors"
|
||||
type="button"
|
||||
aria-label="浏览远程文件"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
浏览
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>已选择 {{ remoteSourcePaths.length }} 个文件</span>
|
||||
<button
|
||||
v-if="remoteSourcePaths.length"
|
||||
@click="clearRemoteSources"
|
||||
class="hover:text-slate-200 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
aria-label="清空已选择源文件"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="remoteSourcePaths.length" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="p in remoteSourcePaths"
|
||||
:key="p"
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-slate-700 bg-slate-950/20 px-2 py-1 text-xs text-slate-200"
|
||||
:title="p"
|
||||
>
|
||||
<span class="max-w-[260px] truncate">{{ basename(p) }}</span>
|
||||
<button
|
||||
@click="removeRemoteSourcePath(p)"
|
||||
class="w-6 h-6 grid place-items-center rounded hover:bg-slate-800/60 text-slate-300 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
:aria-label="'移除 ' + basename(p)"
|
||||
>
|
||||
<XCircle class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="remoteValidationError" class="text-xs text-red-300">{{ remoteValidationError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label for="remote-target-dir" class="text-sm text-slate-300">目标目录或路径</label>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label for="remote-target-dir" class="text-sm text-slate-300">目标目录或路径</label>
|
||||
<div class="inline-flex rounded-lg border border-slate-700 bg-slate-950/20 p-1">
|
||||
<button
|
||||
@click="remoteTargetMode = 'dir'"
|
||||
class="px-3 py-1 rounded-md text-xs transition-colors"
|
||||
:class="remoteTargetMode === 'dir' ? 'bg-slate-800 text-slate-100' : 'text-slate-400 hover:text-slate-200'"
|
||||
type="button"
|
||||
aria-label="目标为目录"
|
||||
>
|
||||
Directory
|
||||
</button>
|
||||
<button
|
||||
@click="remoteTargetMode = 'path'"
|
||||
:disabled="remoteSourcePaths.length > 1"
|
||||
class="px-3 py-1 rounded-md text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="remoteTargetMode === 'path' ? 'bg-slate-800 text-slate-100' : 'text-slate-400 hover:text-slate-200'"
|
||||
type="button"
|
||||
aria-label="目标为精确路径"
|
||||
>
|
||||
Exact Path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="remote-target-dir"
|
||||
v-model="remoteTargetDirOrPath"
|
||||
type="text"
|
||||
placeholder="/target/dir/"
|
||||
:placeholder="remoteTargetMode === 'dir' ? '/target/dir/' : '/target/file.txt'"
|
||||
class="w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">以 / 结尾视为目录,会自动拼接文件名。</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
<span v-if="remoteTargetMode === 'dir'">Directory 模式会自动拼接文件名;输入可不以 / 结尾。</span>
|
||||
<span v-else>Exact Path 仅支持单文件,且不能以 / 结尾。</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="remoteHasBasenameCollision" class="rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2">
|
||||
<p class="text-xs text-amber-200">检测到重名文件(同名但不同目录)。Directory 模式会覆盖目标目录下的同名文件。</p>
|
||||
<label class="mt-2 flex items-center gap-2 text-xs text-amber-100 cursor-pointer">
|
||||
<input
|
||||
v-model="remoteBasenameCollisionAck"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-amber-500/40 bg-slate-900 text-amber-400 focus:ring-amber-400"
|
||||
/>
|
||||
我已知晓并继续
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
@@ -509,8 +692,9 @@ onMounted(async () => {
|
||||
<SftpFilePickerModal
|
||||
:open="pickerOpen"
|
||||
:connection-id="remoteSourceConnectionId"
|
||||
:multiple="true"
|
||||
@close="pickerOpen = false"
|
||||
@select="(p) => (remoteSourcePath = p)"
|
||||
@select-many="(paths) => mergeRemoteSourcePaths(paths)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user