Merge: 修复传输假成功 + 支持多终端标签页

This commit is contained in:
liumangmang
2026-03-18 23:08:54 +08:00
7 changed files with 248 additions and 81 deletions

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,98 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import { useConnectionsStore } from '../stores/connections'
import TerminalWidget from '../components/TerminalWidget.vue'
import { ArrowLeft, X } from 'lucide-vue-next'
const router = useRouter()
const tabsStore = useTerminalTabsStore()
const connectionsStore = useConnectionsStore()
const tabs = computed(() => tabsStore.tabs)
onMounted(() => {
// 确保连接列表已加载
if (connectionsStore.connections.length === 0) {
connectionsStore.fetchConnections()
}
})
function handleTabClick(tabId: string) {
tabsStore.activate(tabId)
}
function handleTabClose(tabId: string, event: Event) {
event.stopPropagation()
tabsStore.close(tabId)
}
function goBack() {
router.push('/connections')
}
</script>
<template>
<div class="h-full flex flex-col">
<!-- 顶部导航栏 -->
<div class="flex items-center gap-4 px-4 py-3 border-b border-slate-700 bg-slate-800/50">
<button
@click="goBack"
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">终端</h2>
</div>
<!-- 标签栏 -->
<div
v-if="tabs.length > 0"
class="flex items-center gap-2 px-4 py-2 border-b border-slate-700 bg-slate-900/50 overflow-x-auto"
>
<div
v-for="tab in tabs"
:key="tab.id"
@click="handleTabClick(tab.id)"
class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors duration-200 whitespace-nowrap"
:class="tab.active ? 'bg-slate-700 text-slate-100' : 'bg-slate-800 text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'"
>
<span class="text-sm font-medium">{{ tab.title }}</span>
<button
@click="(e) => handleTabClose(tab.id, e)"
class="p-1 rounded hover:bg-slate-600 transition-colors duration-200"
aria-label="关闭标签"
>
<X class="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
<!-- 终端内容区 -->
<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="mb-2">暂无打开的终端</p>
<button
@click="goBack"
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
>
返回连接列表
</button>
</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": {}
}