Compare commits

...

10 Commits

Author SHA1 Message Date
liumangmang
77518b3f97 Fix: 修复 Docker 上传目录解析错误
将 multipart 上传目录改为基于 DATA_DIR 的绝对路径,避免 Tomcat 在容器内把相对路径解析到临时目录。同步让上传控制器复用该配置并补充错误日志,确保本地文件在异步上传期间可用。
2026-03-18 23:46:05 +08:00
liumangmang
6dbd5ae694 Merge: 修复文件上传临时文件丢失问题 2026-03-18 23:25:37 +08:00
liumangmang
e0c734d3d9 Config: 配置 multipart 持久化临时目录 2026-03-18 23:25:14 +08:00
liumangmang
e2f600c264 Fix: 修复文件上传临时文件丢失问题
问题:
- Docker 环境下上传文件时出现 FileNotFoundException
- Tomcat 在异步任务执行前清理了临时文件 /tmp/tomcat.xxx/work/...

解决方案:
1. 配置 multipart.location 为持久化目录 ./data/upload-temp
2. 设置 file-size-threshold: 0 强制立即写入磁盘
3. 修改 SftpController.upload() 方法:
   - 在异步任务执行前将 MultipartFile 保存到持久化位置
   - 异步任务从保存的文件读取而非 MultipartFile.getInputStream()
   - 上传完成或失败后自动清理临时文件

影响范围:
- backend/src/main/resources/application.yml
- backend/src/main/java/com/sshmanager/controller/SftpController.java
2026-03-18 23:24:53 +08:00
liumangmang
f892810763 Merge: 将终端标签页移至左侧边栏 2026-03-18 23:17:01 +08:00
liumangmang
c01c005c07 feat: 将终端标签页移至左侧边栏
- 在 MainLayout 侧边栏添加终端标签区域
- 标签纵向排列在连接列表下方
- 点击标签自动切换到终端工作区并激活
- 简化 TerminalWorkspaceView 移除顶部标签栏和返回按钮
- 不用返回就能切换连接和打开新终端
2026-03-18 23:16:54 +08:00
liumangmang
c760fbdb85 Merge: 修复传输假成功 + 支持多终端标签页 2026-03-18 23:08:54 +08:00
liumangmang
51b479a8f9 feat: 支持应用内多 SSH 终端标签页
- 新增 terminalTabs store 管理标签页状态
- 新增 TerminalWorkspaceView 终端工作区视图
- 修改 ConnectionsView 终端按钮改用标签页模式
- 修改 TerminalView 作为兼容入口自动跳转到工作区
- 同一连接默认只保留一个标签页
- 切换标签时保持各自 SSH 会话不断开
2026-03-18 23:05:03 +08:00
liumangmang
c387cc2487 fix: Local->Many 传输等待后端任务真正完成
- 改用 uploadFile + subscribeUploadProgress 替代 uploadFileWithProgress
- 只有后端任务状态为 success 才标记成功
- 修复显示成功但远端无文件的问题
2026-03-18 23:02:31 +08:00
liumangmang
b0a78fc05a chore: add .worktrees to gitignore 2026-03-18 23:00:15 +08:00
12 changed files with 300 additions and 112 deletions

3
.gitignore vendored
View File

@@ -13,5 +13,8 @@ frontend/dist/
.DS_Store
*.local
# Worktrees
.worktrees/
# Keep frontend .gitignore for frontend-specific rules
!frontend/.gitignore

View File

@@ -9,6 +9,7 @@ import com.sshmanager.service.ConnectionService;
import com.sshmanager.service.SftpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -43,6 +44,7 @@ public class SftpController {
private final ConnectionService connectionService;
private final UserRepository userRepository;
private final SftpService sftpService;
private final String uploadTempLocation;
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
@@ -53,10 +55,12 @@ public class SftpController {
public SftpController(ConnectionService connectionService,
UserRepository userRepository,
SftpService sftpService) {
SftpService sftpService,
@Value("${spring.servlet.multipart.location:/app/data/upload-temp}") String uploadTempLocation) {
this.connectionService = connectionService;
this.userRepository = userRepository;
this.sftpService = sftpService;
this.uploadTempLocation = uploadTempLocation;
}
private Long getCurrentUserId(Authentication auth) {
@@ -290,11 +294,21 @@ public class SftpController {
@RequestParam String path,
@RequestParam("file") MultipartFile file,
Authentication authentication) {
java.io.File tempFile = null;
try {
Long userId = getCurrentUserId(authentication);
String taskId = UUID.randomUUID().toString();
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,
path, file.getOriginalFilename(), file.getSize());
status.setController(this);
@@ -308,11 +322,11 @@ public class SftpController {
try {
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
? "/" + file.getOriginalFilename()
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
? "/" + 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));
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() {
@Override
public void onStart(long totalBytes) {
@@ -334,10 +348,21 @@ public class SftpController {
existing.disconnect();
}
throw new RuntimeException(e);
} finally {
// Clean up temp file after upload completes
if (savedFile.exists()) {
savedFile.delete();
}
}
});
} 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");
// Clean up temp file on error
if (savedFile.exists()) {
savedFile.delete();
}
}
});
status.setFuture(future);
@@ -347,6 +372,11 @@ public class SftpController {
result.put("message", "Upload started");
return ResponseEntity.ok(result);
} 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<>();
error.put("error", e.getMessage());
return ResponseEntity.status(500).body(error);

View File

@@ -9,6 +9,8 @@ spring:
multipart:
max-file-size: 2048MB
max-request-size: 2048MB
location: ${DATA_DIR:/app/data}/upload-temp # 使用容器数据目录,避免被解析为 Tomcat 工作目录
file-size-threshold: 0 # 立即写入磁盘,不使用内存缓冲
datasource:
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver

View File

@@ -41,7 +41,7 @@ WORKDIR /app
COPY --from=backend /build/target/*.jar app.jar
ENV DATA_DIR=/app/data
RUN mkdir -p ${DATA_DIR}
RUN mkdir -p ${DATA_DIR}/upload-temp
EXPOSE 48080

View File

@@ -1,20 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ref, computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
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 router = useRouter()
const authStore = useAuthStore()
const connectionsStore = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const sidebarOpen = ref(false)
const terminalTabs = computed(() => tabsStore.tabs)
connectionsStore.fetchConnections().catch(() => {})
function closeSidebar() {
sidebarOpen.value = false
}
function handleTabClick(tabId: string) {
tabsStore.activate(tabId)
router.push('/terminal')
closeSidebar()
}
function handleTabClose(tabId: string, event: Event) {
event.stopPropagation()
tabsStore.close(tabId)
}
</script>
<template>
@@ -38,7 +54,7 @@ function closeSidebar() {
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
</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
to="/transfers"
@click="closeSidebar"
@@ -59,6 +75,32 @@ function closeSidebar() {
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
<span>连接列表</span>
</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>
<div class="p-4 border-t border-slate-700">
<button

View File

@@ -29,6 +29,11 @@ const routes: RouteRecordRaw[] = [
name: 'Connections',
component: () => import('../views/ConnectionsView.vue'),
},
{
path: 'terminal',
name: 'TerminalWorkspace',
component: () => import('../views/TerminalWorkspaceView.vue'),
},
{
path: 'terminal/:id',
name: 'Terminal',

View 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,
}
})

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
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 TransferItemStatus = 'queued' | 'running' | 'success' | 'error' | 'cancelled'
@@ -188,17 +188,11 @@ export const useTransfersStore = defineStore('transfers', () => {
runs.value = [run, ...runs.value]
const activeXhrs: XMLHttpRequest[] = []
let cancelled = false
const unsubscribers: (() => void)[] = []
controllers.set(runId, {
abortAll: () => {
for (const xhr of activeXhrs) {
try {
xhr.abort()
} catch {
// ignore
}
}
cancelled = true
},
unsubscribers,
})
@@ -211,7 +205,7 @@ export const useTransfersStore = defineStore('transfers', () => {
if (itemIndex === -1) continue
const item = runItems[itemIndex]!
tasks.push(async () => {
if (item.status === 'cancelled') return
if (item.status === 'cancelled' || cancelled) return
item.status = 'running'
item.progress = 0
item.startedAt = now()
@@ -219,28 +213,24 @@ export const useTransfersStore = defineStore('transfers', () => {
const stopPseudoProgress = startPseudoProgress(item)
try {
const xhr = uploadFileWithProgress(connectionId, targetDir || '', file)
activeXhrs.push(xhr)
// 发起上传并获取 taskId
const uploadRes = await uploadFile(connectionId, targetDir || '', file)
const taskId = uploadRes.data.taskId
// 订阅上传任务进度,等待真正完成
await new Promise<void>((resolve, reject) => {
let lastTick = 0
xhr.onProgress = (percent) => {
console.log('[Transfers] onProgress callback fired:', percent, 'item:', item.label)
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
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()
} else if (task.status === 'error') {
reject(new Error(task.error || 'Upload failed'))
}
console.log('[Transfers] Set onProgress callback for:', item.label)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error(xhr.responseText || `HTTP ${xhr.status}`))
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.onabort = () => reject(new Error('Cancelled'))
})
unsubscribers.push(unsubscribe)
})
item.status = 'success'

View File

@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import ConnectionForm from '../components/ConnectionForm.vue'
import {
@@ -17,6 +18,7 @@ import {
const router = useRouter()
const store = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const showForm = ref(false)
const editingConn = ref<Connection | null>(null)
@@ -53,7 +55,8 @@ async function handleDelete(conn: Connection) {
}
function openTerminal(conn: Connection) {
router.push(`/terminal/${conn.id}`)
tabsStore.openOrFocus(conn)
router.push('/terminal')
}
function openSftp(conn: Connection) {

View File

@@ -1,46 +1,37 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import TerminalWidget from '../components/TerminalWidget.vue'
import { ArrowLeft } from 'lucide-vue-next'
import { useTerminalTabsStore } from '../stores/terminalTabs'
const route = useRoute()
const router = useRouter()
const store = useConnectionsStore()
const connectionsStore = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
if (!conn.value) {
store.fetchConnections().then(() => {
conn.value = store.getConnection(connectionId.value)
})
onMounted(async () => {
// 确保连接列表已加载
if (connectionsStore.connections.length === 0) {
await connectionsStore.fetchConnections()
}
const conn = connectionsStore.getConnection(connectionId.value)
if (conn) {
// 打开或聚焦该连接的标签页
tabsStore.openOrFocus(conn)
// 跳转到工作区
router.replace('/terminal')
} else {
// 连接不存在,返回连接列表
router.replace('/connections')
}
})
</script>
<template>
<div class="h-full flex flex-col">
<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 class="h-full flex items-center justify-center text-slate-400">
正在打开终端...
</div>
</template>

View 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
View File

@@ -0,0 +1,6 @@
{
"name": "fix-transfer-and-multi-terminal",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}