diff --git a/frontend/src/components/MigrationPrompt.vue b/frontend/src/components/MigrationPrompt.vue new file mode 100644 index 0000000..a5a6ff0 --- /dev/null +++ b/frontend/src/components/MigrationPrompt.vue @@ -0,0 +1,92 @@ + + + + + + + + + 欢迎使用新版布局 + + 我们检测到您之前使用过旧版布局,是否要迁移您的会话数据? + + + + + + + + + + 迁移内容: + + • 所有连接将被导入到会话树 + • 打开的终端和 SFTP 标签页将被保留 + • 您可以随时切换回旧版布局 + + + + + + 不再显示此提示 + + + + + + 立即迁移 + + + 稍后再说 + + + + + diff --git a/frontend/src/components/SessionTree.vue b/frontend/src/components/SessionTree.vue index 2459504..7e749e9 100644 --- a/frontend/src/components/SessionTree.vue +++ b/frontend/src/components/SessionTree.vue @@ -1,7 +1,8 @@ + + + + + + connectionsStore.connections.length, + (newLength, oldLength) => { + if (newLength > oldLength) { + // New connection added + treeStore.syncNewConnections() + } else if (newLength < oldLength) { + // Connection deleted + treeStore.syncDeletedConnections() + } + } + ) + + // Watch for connection name changes + watch( + () => connectionsStore.connections.map(c => ({ id: c.id, name: c.name })), + (newConnections, oldConnections) => { + if (!oldConnections) return + + newConnections.forEach((newConn, index) => { + const oldConn = oldConnections[index] + if (oldConn && oldConn.id === newConn.id && oldConn.name !== newConn.name) { + treeStore.syncConnectionName(newConn.id, newConn.name) + } + }) + }, + { deep: true } + ) +} diff --git a/frontend/src/composables/useTreeDragDrop.ts b/frontend/src/composables/useTreeDragDrop.ts new file mode 100644 index 0000000..7739b26 --- /dev/null +++ b/frontend/src/composables/useTreeDragDrop.ts @@ -0,0 +1,112 @@ +import { ref } from 'vue' +import { useSessionTreeStore } from '../stores/sessionTree' +import type { SessionTreeNode } from '../types/sessionTree' + +export function useTreeDragDrop() { + const treeStore = useSessionTreeStore() + const draggedNode = ref(null) + const dropTarget = ref(null) + const dropPosition = ref<'before' | 'after' | 'inside' | null>(null) + + function canDropOn(target: SessionTreeNode, dragged: SessionTreeNode): boolean { + // Can't drop on itself + if (target.id === dragged.id) return false + + // Can't drop on own descendants + if (isDescendant(target.id, dragged.id)) return false + + return true + } + + function isDescendant(nodeId: string, ancestorId: string): boolean { + const node = treeStore.nodes.find(n => n.id === nodeId) + if (!node || !node.parentId) return false + if (node.parentId === ancestorId) return true + return isDescendant(node.parentId, ancestorId) + } + + function handleDragStart(node: SessionTreeNode) { + draggedNode.value = node + } + + function handleDragOver(event: DragEvent, target: SessionTreeNode) { + event.preventDefault() + + if (!draggedNode.value || !canDropOn(target, draggedNode.value)) { + dropTarget.value = null + dropPosition.value = null + return + } + + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() + const y = event.clientY - rect.top + const height = rect.height + + dropTarget.value = target + + // Determine drop position based on cursor position + if (target.type === 'folder') { + if (y < height * 0.25) { + dropPosition.value = 'before' + } else if (y > height * 0.75) { + dropPosition.value = 'after' + } else { + dropPosition.value = 'inside' + } + } else { + // Connections can only have before/after + dropPosition.value = y < height / 2 ? 'before' : 'after' + } + } + + function handleDragLeave() { + dropTarget.value = null + dropPosition.value = null + } + + function handleDrop(event: DragEvent) { + event.preventDefault() + + if (!draggedNode.value || !dropTarget.value || !dropPosition.value) { + return + } + + const dragged = draggedNode.value + const target = dropTarget.value + const position = dropPosition.value + + if (position === 'inside' && target.type === 'folder') { + // Move inside folder + treeStore.moveNode(dragged.id, target.id, 0) + } else { + // Move before/after target + const targetOrder = target.order + const newParentId = target.parentId + const newOrder = position === 'before' ? targetOrder : targetOrder + 1 + + treeStore.moveNode(dragged.id, newParentId, newOrder) + } + + // Reset state + draggedNode.value = null + dropTarget.value = null + dropPosition.value = null + } + + function handleDragEnd() { + draggedNode.value = null + dropTarget.value = null + dropPosition.value = null + } + + return { + draggedNode, + dropTarget, + dropPosition, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd, + } +} diff --git a/frontend/src/layouts/MobaLayout.vue b/frontend/src/layouts/MobaLayout.vue index 57b9fb3..a134f33 100644 --- a/frontend/src/layouts/MobaLayout.vue +++ b/frontend/src/layouts/MobaLayout.vue @@ -1,22 +1,55 @@ @@ -32,5 +65,12 @@ onMounted(() => { + + diff --git a/frontend/src/stores/sessionTree.ts b/frontend/src/stores/sessionTree.ts index 274826b..5d579a7 100644 --- a/frontend/src/stores/sessionTree.ts +++ b/frontend/src/stores/sessionTree.ts @@ -155,5 +155,62 @@ export const useSessionTreeStore = defineStore('sessionTree', { this.createConnectionNode(conn.id, conn.name, defaultFolder.id) }) }, + + // Sync with connections store - add new connections + syncNewConnections() { + const connectionsStore = useConnectionsStore() + const existingConnectionIds = new Set( + this.nodes + .filter(n => n.type === 'connection' && n.connectionId) + .map(n => n.connectionId!) + ) + + const newConnections = connectionsStore.connections.filter( + conn => !existingConnectionIds.has(conn.id) + ) + + if (newConnections.length > 0) { + // Add to root or default folder + let targetParentId: string | null = null + const defaultFolder = this.nodes.find( + n => n.type === 'folder' && n.name === '我的连接' + ) + if (defaultFolder) { + targetParentId = defaultFolder.id + } + + newConnections.forEach(conn => { + this.createConnectionNode(conn.id, conn.name, targetParentId) + }) + } + }, + + // Sync with connections store - remove deleted connections + syncDeletedConnections() { + const connectionsStore = useConnectionsStore() + const validConnectionIds = new Set( + connectionsStore.connections.map(c => c.id) + ) + + const nodesToDelete = this.nodes.filter( + n => n.type === 'connection' && + n.connectionId && + !validConnectionIds.has(n.connectionId) + ) + + nodesToDelete.forEach(node => { + this.deleteNode(node.id) + }) + }, + + // Update connection name when changed + syncConnectionName(connectionId: number, newName: string) { + const node = this.nodes.find( + n => n.type === 'connection' && n.connectionId === connectionId + ) + if (node && node.name !== newName) { + this.renameNode(node.id, newName) + } + }, }, })
+ 我们检测到您之前使用过旧版布局,是否要迁移您的会话数据? +
迁移内容: