Compare commits
10 Commits
80fc5c8a0f
...
77518b3f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77518b3f97 | ||
|
|
6dbd5ae694 | ||
|
|
e0c734d3d9 | ||
|
|
e2f600c264 | ||
|
|
f892810763 | ||
|
|
c01c005c07 | ||
|
|
c760fbdb85 | ||
|
|
51b479a8f9 | ||
|
|
c387cc2487 | ||
|
|
b0a78fc05a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,5 +13,8 @@ frontend/dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Keep frontend .gitignore for frontend-specific rules
|
# Keep frontend .gitignore for frontend-specific rules
|
||||||
!frontend/.gitignore
|
!frontend/.gitignore
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.sshmanager.service.ConnectionService;
|
|||||||
import com.sshmanager.service.SftpService;
|
import com.sshmanager.service.SftpService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -43,6 +44,7 @@ public class SftpController {
|
|||||||
private final ConnectionService connectionService;
|
private final ConnectionService connectionService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final SftpService sftpService;
|
private final SftpService sftpService;
|
||||||
|
private final String uploadTempLocation;
|
||||||
|
|
||||||
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
||||||
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||||
@@ -53,10 +55,12 @@ public class SftpController {
|
|||||||
|
|
||||||
public SftpController(ConnectionService connectionService,
|
public SftpController(ConnectionService connectionService,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
SftpService sftpService) {
|
SftpService sftpService,
|
||||||
|
@Value("${spring.servlet.multipart.location:/app/data/upload-temp}") String uploadTempLocation) {
|
||||||
this.connectionService = connectionService;
|
this.connectionService = connectionService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.sftpService = sftpService;
|
this.sftpService = sftpService;
|
||||||
|
this.uploadTempLocation = uploadTempLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long getCurrentUserId(Authentication auth) {
|
private Long getCurrentUserId(Authentication auth) {
|
||||||
@@ -290,11 +294,21 @@ public class SftpController {
|
|||||||
@RequestParam String path,
|
@RequestParam String path,
|
||||||
@RequestParam("file") MultipartFile file,
|
@RequestParam("file") MultipartFile file,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
java.io.File tempFile = null;
|
||||||
try {
|
try {
|
||||||
Long userId = getCurrentUserId(authentication);
|
Long userId = getCurrentUserId(authentication);
|
||||||
String taskId = UUID.randomUUID().toString();
|
String taskId = UUID.randomUUID().toString();
|
||||||
String taskKey = uploadTaskKey(userId, taskId);
|
String taskKey = uploadTaskKey(userId, taskId);
|
||||||
|
|
||||||
|
// Save file to persistent location before async processing
|
||||||
|
java.io.File uploadTempDir = new java.io.File(uploadTempLocation);
|
||||||
|
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());
|
||||||
|
file.transferTo(tempFile);
|
||||||
|
final java.io.File savedFile = tempFile;
|
||||||
|
|
||||||
UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId,
|
UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId,
|
||||||
path, file.getOriginalFilename(), file.getSize());
|
path, file.getOriginalFilename(), file.getSize());
|
||||||
status.setController(this);
|
status.setController(this);
|
||||||
@@ -308,11 +322,11 @@ public class SftpController {
|
|||||||
try {
|
try {
|
||||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||||
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
||||||
? "/" + file.getOriginalFilename()
|
? "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1)
|
||||||
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
|
: (path.endsWith("/") ? path + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) : path + "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1));
|
||||||
|
|
||||||
AtomicLong transferred = new AtomicLong(0);
|
AtomicLong transferred = new AtomicLong(0);
|
||||||
try (java.io.InputStream in = file.getInputStream()) {
|
try (java.io.InputStream in = new java.io.FileInputStream(savedFile)) {
|
||||||
sftpService.upload(session, remotePath, in, new SftpService.TransferProgressListener() {
|
sftpService.upload(session, remotePath, in, new SftpService.TransferProgressListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onStart(long totalBytes) {
|
public void onStart(long totalBytes) {
|
||||||
@@ -334,10 +348,21 @@ public class SftpController {
|
|||||||
existing.disconnect();
|
existing.disconnect();
|
||||||
}
|
}
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
|
} finally {
|
||||||
|
// Clean up temp file after upload completes
|
||||||
|
if (savedFile.exists()) {
|
||||||
|
savedFile.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
log.error("Async upload failed, taskId={}, connectionId={}, tempFile={}",
|
||||||
|
taskId, connectionId, savedFile.getAbsolutePath(), e);
|
||||||
status.markError(e.getMessage() != null ? e.getMessage() : "Upload failed");
|
status.markError(e.getMessage() != null ? e.getMessage() : "Upload failed");
|
||||||
|
// Clean up temp file on error
|
||||||
|
if (savedFile.exists()) {
|
||||||
|
savedFile.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
status.setFuture(future);
|
status.setFuture(future);
|
||||||
@@ -347,6 +372,11 @@ public class SftpController {
|
|||||||
result.put("message", "Upload started");
|
result.put("message", "Upload started");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to prepare upload temp file, connectionId={}", connectionId, e);
|
||||||
|
// Clean up temp file if initial save failed
|
||||||
|
if (tempFile != null && tempFile.exists()) {
|
||||||
|
tempFile.delete();
|
||||||
|
}
|
||||||
Map<String, Object> error = new HashMap<>();
|
Map<String, Object> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", e.getMessage());
|
||||||
return ResponseEntity.status(500).body(error);
|
return ResponseEntity.status(500).body(error);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ spring:
|
|||||||
multipart:
|
multipart:
|
||||||
max-file-size: 2048MB
|
max-file-size: 2048MB
|
||||||
max-request-size: 2048MB
|
max-request-size: 2048MB
|
||||||
|
location: ${DATA_DIR:/app/data}/upload-temp # 使用容器数据目录,避免被解析为 Tomcat 工作目录
|
||||||
|
file-size-threshold: 0 # 立即写入磁盘,不使用内存缓冲
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: org.h2.Driver
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=backend /build/target/*.jar app.jar
|
COPY --from=backend /build/target/*.jar app.jar
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data
|
ENV DATA_DIR=/app/data
|
||||||
RUN mkdir -p ${DATA_DIR}
|
RUN mkdir -p ${DATA_DIR}/upload-temp
|
||||||
|
|
||||||
EXPOSE 48080
|
EXPOSE 48080
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import { ArrowLeftRight, Server, LogOut, Menu, X } from 'lucide-vue-next'
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
|
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const connectionsStore = useConnectionsStore()
|
const connectionsStore = useConnectionsStore()
|
||||||
|
const tabsStore = useTerminalTabsStore()
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
|
const terminalTabs = computed(() => tabsStore.tabs)
|
||||||
|
|
||||||
connectionsStore.fetchConnections().catch(() => {})
|
connectionsStore.fetchConnections().catch(() => {})
|
||||||
|
|
||||||
function closeSidebar() {
|
function closeSidebar() {
|
||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
}
|
}
|
||||||
</script>
|
|
||||||
|
function handleTabClick(tabId: string) {
|
||||||
|
tabsStore.activate(tabId)
|
||||||
|
router.push('/terminal')
|
||||||
|
closeSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabClose(tabId: string, event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
tabsStore.close(tabId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen bg-slate-900">
|
<div class="flex h-screen bg-slate-900">
|
||||||
@@ -38,27 +54,53 @@ function closeSidebar() {
|
|||||||
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
||||||
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
|
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4 overflow-y-auto">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/transfers"
|
to="/transfers"
|
||||||
@click="closeSidebar"
|
@click="closeSidebar"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/transfers' }"
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/transfers' }"
|
||||||
aria-label="传输"
|
aria-label="传输"
|
||||||
>
|
>
|
||||||
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||||
<span>传输</span>
|
<span>传输</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/connections"
|
to="/connections"
|
||||||
@click="closeSidebar"
|
@click="closeSidebar"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
||||||
aria-label="连接列表"
|
aria-label="连接列表"
|
||||||
>
|
>
|
||||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||||
<span>连接列表</span>
|
<span>连接列表</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<!-- 终端标签区域 -->
|
||||||
|
<div v-if="terminalTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<span>终端</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 mt-2">
|
||||||
|
<button
|
||||||
|
v-for="tab in terminalTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="handleTabClick(tab.id)"
|
||||||
|
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset group"
|
||||||
|
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === '/terminal' }"
|
||||||
|
>
|
||||||
|
<span class="truncate text-sm">{{ tab.title }}</span>
|
||||||
|
<button
|
||||||
|
@click="(e) => handleTabClose(tab.id, e)"
|
||||||
|
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-all duration-200 flex-shrink-0"
|
||||||
|
aria-label="关闭标签"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="p-4 border-t border-slate-700">
|
<div class="p-4 border-t border-slate-700">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -24,16 +24,21 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Transfers',
|
name: 'Transfers',
|
||||||
component: () => import('../views/TransfersView.vue'),
|
component: () => import('../views/TransfersView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'connections',
|
path: 'connections',
|
||||||
name: 'Connections',
|
name: 'Connections',
|
||||||
component: () => import('../views/ConnectionsView.vue'),
|
component: () => import('../views/ConnectionsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'terminal',
|
||||||
|
name: 'TerminalWorkspace',
|
||||||
|
component: () => import('../views/TerminalWorkspaceView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'terminal/:id',
|
path: 'terminal/:id',
|
||||||
name: 'Terminal',
|
name: 'Terminal',
|
||||||
component: () => import('../views/TerminalView.vue'),
|
component: () => import('../views/TerminalView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sftp/:id',
|
path: 'sftp/:id',
|
||||||
name: 'Sftp',
|
name: 'Sftp',
|
||||||
|
|||||||
74
frontend/src/stores/terminalTabs.ts
Normal file
74
frontend/src/stores/terminalTabs.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Connection } from '../api/connections'
|
||||||
|
|
||||||
|
export interface TerminalTab {
|
||||||
|
id: string
|
||||||
|
connectionId: number
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTerminalTabsStore = defineStore('terminalTabs', () => {
|
||||||
|
const tabs = ref<TerminalTab[]>([])
|
||||||
|
const activeTabId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
||||||
|
|
||||||
|
function generateTabId() {
|
||||||
|
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrFocus(connection: Connection) {
|
||||||
|
// 检查是否已存在该连接的标签页
|
||||||
|
const existing = tabs.value.find(t => t.connectionId === connection.id)
|
||||||
|
if (existing) {
|
||||||
|
activate(existing.id)
|
||||||
|
return existing.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新标签页
|
||||||
|
const newTab: TerminalTab = {
|
||||||
|
id: generateTabId(),
|
||||||
|
connectionId: connection.id,
|
||||||
|
title: connection.name,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.value.push(newTab)
|
||||||
|
activate(newTab.id)
|
||||||
|
return newTab.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(tabId: string) {
|
||||||
|
tabs.value.forEach(t => {
|
||||||
|
t.active = t.id === tabId
|
||||||
|
})
|
||||||
|
activeTabId.value = tabId
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(tabId: string) {
|
||||||
|
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const wasActive = tabs.value[index]!.active
|
||||||
|
tabs.value.splice(index, 1)
|
||||||
|
|
||||||
|
// 如果关闭的是活动标签,激活相邻标签
|
||||||
|
if (wasActive && tabs.value.length > 0) {
|
||||||
|
const newIndex = Math.min(index, tabs.value.length - 1)
|
||||||
|
activate(tabs.value[newIndex]!.id)
|
||||||
|
} else if (tabs.value.length === 0) {
|
||||||
|
activeTabId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
activeTab,
|
||||||
|
openOrFocus,
|
||||||
|
activate,
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
import { createRemoteTransferTask, subscribeRemoteTransferProgress, uploadFileWithProgress } from '../api/sftp'
|
import { createRemoteTransferTask, subscribeRemoteTransferProgress, uploadFile, subscribeUploadProgress } from '../api/sftp'
|
||||||
|
|
||||||
export type TransferMode = 'LOCAL_TO_MANY' | 'REMOTE_TO_MANY'
|
export type TransferMode = 'LOCAL_TO_MANY' | 'REMOTE_TO_MANY'
|
||||||
export type TransferItemStatus = 'queued' | 'running' | 'success' | 'error' | 'cancelled'
|
export type TransferItemStatus = 'queued' | 'running' | 'success' | 'error' | 'cancelled'
|
||||||
@@ -188,17 +188,11 @@ export const useTransfersStore = defineStore('transfers', () => {
|
|||||||
|
|
||||||
runs.value = [run, ...runs.value]
|
runs.value = [run, ...runs.value]
|
||||||
|
|
||||||
const activeXhrs: XMLHttpRequest[] = []
|
let cancelled = false
|
||||||
const unsubscribers: (() => void)[] = []
|
const unsubscribers: (() => void)[] = []
|
||||||
controllers.set(runId, {
|
controllers.set(runId, {
|
||||||
abortAll: () => {
|
abortAll: () => {
|
||||||
for (const xhr of activeXhrs) {
|
cancelled = true
|
||||||
try {
|
|
||||||
xhr.abort()
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
unsubscribers,
|
unsubscribers,
|
||||||
})
|
})
|
||||||
@@ -211,7 +205,7 @@ export const useTransfersStore = defineStore('transfers', () => {
|
|||||||
if (itemIndex === -1) continue
|
if (itemIndex === -1) continue
|
||||||
const item = runItems[itemIndex]!
|
const item = runItems[itemIndex]!
|
||||||
tasks.push(async () => {
|
tasks.push(async () => {
|
||||||
if (item.status === 'cancelled') return
|
if (item.status === 'cancelled' || cancelled) return
|
||||||
item.status = 'running'
|
item.status = 'running'
|
||||||
item.progress = 0
|
item.progress = 0
|
||||||
item.startedAt = now()
|
item.startedAt = now()
|
||||||
@@ -219,28 +213,24 @@ export const useTransfersStore = defineStore('transfers', () => {
|
|||||||
const stopPseudoProgress = startPseudoProgress(item)
|
const stopPseudoProgress = startPseudoProgress(item)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const xhr = uploadFileWithProgress(connectionId, targetDir || '', file)
|
// 发起上传并获取 taskId
|
||||||
activeXhrs.push(xhr)
|
const uploadRes = await uploadFile(connectionId, targetDir || '', file)
|
||||||
|
const taskId = uploadRes.data.taskId
|
||||||
|
|
||||||
|
// 订阅上传任务进度,等待真正完成
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let lastTick = 0
|
const unsubscribe = subscribeUploadProgress(taskId, (task) => {
|
||||||
xhr.onProgress = (percent) => {
|
const progress = Math.max(0, Math.min(100, task.progress || 0))
|
||||||
console.log('[Transfers] onProgress callback fired:', percent, 'item:', item.label)
|
item.progress = progress
|
||||||
const t = now()
|
|
||||||
if (t - lastTick < 100 && percent !== 100) return
|
|
||||||
lastTick = t
|
|
||||||
const newProgress = Math.max(item.progress || 0, Math.max(0, Math.min(100, percent)))
|
|
||||||
console.log('[Transfers] Updating item.progress from', item.progress, 'to', newProgress)
|
|
||||||
item.progress = newProgress
|
|
||||||
runs.value = [...runs.value]
|
runs.value = [...runs.value]
|
||||||
}
|
|
||||||
console.log('[Transfers] Set onProgress callback for:', item.label)
|
if (task.status === 'success') {
|
||||||
xhr.onload = () => {
|
resolve()
|
||||||
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
} else if (task.status === 'error') {
|
||||||
else reject(new Error(xhr.responseText || `HTTP ${xhr.status}`))
|
reject(new Error(task.error || 'Upload failed'))
|
||||||
}
|
}
|
||||||
xhr.onerror = () => reject(new Error('Network error'))
|
})
|
||||||
xhr.onabort = () => reject(new Error('Cancelled'))
|
unsubscribers.push(unsubscribe)
|
||||||
})
|
})
|
||||||
|
|
||||||
item.status = 'success'
|
item.status = 'success'
|
||||||
|
|||||||
@@ -2,21 +2,23 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
|
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Plus,
|
Plus,
|
||||||
Terminal,
|
Terminal,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Key,
|
Key,
|
||||||
Lock,
|
Lock,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
|
const tabsStore = useTerminalTabsStore()
|
||||||
|
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const editingConn = ref<Connection | null>(null)
|
const editingConn = ref<Connection | null>(null)
|
||||||
@@ -53,12 +55,13 @@ async function handleDelete(conn: Connection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openTerminal(conn: Connection) {
|
function openTerminal(conn: Connection) {
|
||||||
router.push(`/terminal/${conn.id}`)
|
tabsStore.openOrFocus(conn)
|
||||||
}
|
router.push('/terminal')
|
||||||
|
|
||||||
function openSftp(conn: Connection) {
|
|
||||||
router.push(`/sftp/${conn.id}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSftp(conn: Connection) {
|
||||||
|
router.push(`/sftp/${conn.id}`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,46 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import TerminalWidget from '../components/TerminalWidget.vue'
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
import { ArrowLeft } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useConnectionsStore()
|
const connectionsStore = useConnectionsStore()
|
||||||
|
const tabsStore = useTerminalTabsStore()
|
||||||
|
|
||||||
const connectionId = computed(() => Number(route.params.id))
|
const connectionId = computed(() => Number(route.params.id))
|
||||||
const conn = ref(store.getConnection(connectionId.value))
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
conn.value = store.getConnection(connectionId.value)
|
// 确保连接列表已加载
|
||||||
if (!conn.value) {
|
if (connectionsStore.connections.length === 0) {
|
||||||
store.fetchConnections().then(() => {
|
await connectionsStore.fetchConnections()
|
||||||
conn.value = store.getConnection(connectionId.value)
|
}
|
||||||
})
|
|
||||||
|
const conn = connectionsStore.getConnection(connectionId.value)
|
||||||
|
if (conn) {
|
||||||
|
// 打开或聚焦该连接的标签页
|
||||||
|
tabsStore.openOrFocus(conn)
|
||||||
|
// 跳转到工作区
|
||||||
|
router.replace('/terminal')
|
||||||
|
} else {
|
||||||
|
// 连接不存在,返回连接列表
|
||||||
|
router.replace('/connections')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex items-center justify-center text-slate-400">
|
||||||
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
正在打开终端...
|
||||||
<button
|
|
||||||
@click="router.push('/connections')"
|
|
||||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
|
||||||
aria-label="返回"
|
|
||||||
>
|
|
||||||
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<h2 class="text-lg font-semibold text-slate-100">
|
|
||||||
{{ conn?.name || '终端' }} - {{ conn?.username }}@{{ conn?.host }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 p-4">
|
|
||||||
<TerminalWidget v-if="conn" :connection-id="conn.id" />
|
|
||||||
<div v-else class="flex items-center justify-center h-64 text-slate-400">
|
|
||||||
加载中...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
42
frontend/src/views/TerminalWorkspaceView.vue
Normal file
42
frontend/src/views/TerminalWorkspaceView.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import TerminalWidget from '../components/TerminalWidget.vue'
|
||||||
|
|
||||||
|
const tabsStore = useTerminalTabsStore()
|
||||||
|
const connectionsStore = useConnectionsStore()
|
||||||
|
|
||||||
|
const tabs = computed(() => tabsStore.tabs)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 确保连接列表已加载
|
||||||
|
if (connectionsStore.connections.length === 0) {
|
||||||
|
connectionsStore.fetchConnections()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- 终端内容区 -->
|
||||||
|
<div class="flex-1 min-h-0 p-4">
|
||||||
|
<div v-if="tabs.length === 0" class="flex items-center justify-center h-full text-slate-400">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-lg mb-2">暂无打开的终端</p>
|
||||||
|
<p class="text-sm text-slate-500">从左侧连接列表点击"终端"按钮打开</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-full">
|
||||||
|
<div
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
v-show="tab.active"
|
||||||
|
class="h-full"
|
||||||
|
>
|
||||||
|
<TerminalWidget :connection-id="tab.connectionId" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "fix-transfer-and-multi-terminal",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user