From c387cc24873604645cd048822294a1a00eb60a67 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Wed, 18 Mar 2026 23:02:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Local->Many=20=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E5=90=8E=E7=AB=AF=E4=BB=BB=E5=8A=A1=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改用 uploadFile + subscribeUploadProgress 替代 uploadFileWithProgress - 只有后端任务状态为 success 才标记成功 - 修复显示成功但远端无文件的问题 --- frontend/src/stores/transfers.ts | 48 +++++++++++++------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/frontend/src/stores/transfers.ts b/frontend/src/stores/transfers.ts index d207a1b..081cabc 100644 --- a/frontend/src/stores/transfers.ts +++ b/frontend/src/stores/transfers.ts @@ -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((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] - } - 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')) + + if (task.status === 'success') { + resolve() + } else if (task.status === 'error') { + reject(new Error(task.error || 'Upload failed')) + } + }) + unsubscribers.push(unsubscribe) }) item.status = 'success' From 51b479a8f9d8eb7927834c7b636d8d1185b2bd13 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Wed, 18 Mar 2026 23:05:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=86=85=E5=A4=9A=20SSH=20=E7=BB=88=E7=AB=AF=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 terminalTabs store 管理标签页状态 - 新增 TerminalWorkspaceView 终端工作区视图 - 修改 ConnectionsView 终端按钮改用标签页模式 - 修改 TerminalView 作为兼容入口自动跳转到工作区 - 同一连接默认只保留一个标签页 - 切换标签时保持各自 SSH 会话不断开 --- frontend/src/router/index.ts | 17 ++-- frontend/src/stores/terminalTabs.ts | 74 +++++++++++++++ frontend/src/views/ConnectionsView.vue | 35 +++---- frontend/src/views/TerminalView.vue | 51 +++++----- frontend/src/views/TerminalWorkspaceView.vue | 98 ++++++++++++++++++++ package-lock.json | 6 ++ 6 files changed, 229 insertions(+), 52 deletions(-) create mode 100644 frontend/src/stores/terminalTabs.ts create mode 100644 frontend/src/views/TerminalWorkspaceView.vue create mode 100644 package-lock.json diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e95ab11..5bc102e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,16 +24,21 @@ const routes: RouteRecordRaw[] = [ name: 'Transfers', component: () => import('../views/TransfersView.vue'), }, - { - path: 'connections', - name: 'Connections', - component: () => import('../views/ConnectionsView.vue'), - }, + { + path: 'connections', + name: 'Connections', + component: () => import('../views/ConnectionsView.vue'), + }, + { + path: 'terminal', + name: 'TerminalWorkspace', + component: () => import('../views/TerminalWorkspaceView.vue'), + }, { path: 'terminal/:id', name: 'Terminal', component: () => import('../views/TerminalView.vue'), - }, + }, { path: 'sftp/:id', name: 'Sftp', diff --git a/frontend/src/stores/terminalTabs.ts b/frontend/src/stores/terminalTabs.ts new file mode 100644 index 0000000..bca5153 --- /dev/null +++ b/frontend/src/stores/terminalTabs.ts @@ -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([]) + const activeTabId = ref(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, + } +}) diff --git a/frontend/src/views/ConnectionsView.vue b/frontend/src/views/ConnectionsView.vue index 2915166..b272727 100644 --- a/frontend/src/views/ConnectionsView.vue +++ b/frontend/src/views/ConnectionsView.vue @@ -2,21 +2,23 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' 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 { - Server, - Plus, - Terminal, - FolderOpen, - Pencil, - Trash2, - Key, - Lock, -} from 'lucide-vue-next' +import { + Server, + Plus, + Terminal, + FolderOpen, + Pencil, + Trash2, + Key, + Lock, +} from 'lucide-vue-next' const router = useRouter() const store = useConnectionsStore() +const tabsStore = useTerminalTabsStore() const showForm = ref(false) const editingConn = ref(null) @@ -53,12 +55,13 @@ async function handleDelete(conn: Connection) { } function openTerminal(conn: Connection) { - router.push(`/terminal/${conn.id}`) -} - -function openSftp(conn: Connection) { - router.push(`/sftp/${conn.id}`) + tabsStore.openOrFocus(conn) + router.push('/terminal') } + +function openSftp(conn: Connection) { + router.push(`/sftp/${conn.id}`) +}