feat: implement drag-drop and data migration (Phase 3 & 5)
Phase 3 - Drag-drop functionality: - Add useTreeDragDrop composable with drag state management - Implement drag constraints (prevent dropping on self/descendants) - Add visual feedback (opacity, drop indicators, highlight) - Support drop positions: before/after/inside folder Phase 5 - Data migration and sync: - Add MigrationPrompt component for first-time users - Implement bidirectional sync between connections and session tree - Add syncNewConnections/syncDeletedConnections/syncConnectionName methods - Create useConnectionSync composable for automatic sync - Support migration from old layout with user prompt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
92
frontend/src/components/MigrationPrompt.vue
Normal file
92
frontend/src/components/MigrationPrompt.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Info, X } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
migrate: []
|
||||
dismiss: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const dontShowAgain = ref(false)
|
||||
|
||||
function handleMigrate() {
|
||||
if (dontShowAgain.value) {
|
||||
localStorage.setItem('ssh-manager.migration-dismissed', 'true')
|
||||
}
|
||||
emit('migrate')
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
if (dontShowAgain.value) {
|
||||
localStorage.setItem('ssh-manager.migration-dismissed', 'true')
|
||||
}
|
||||
emit('dismiss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="bg-slate-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="flex items-start gap-3 p-5 border-b border-slate-700">
|
||||
<Info class="w-5 h-5 text-cyan-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h3 class="text-base font-medium text-slate-100">欢迎使用新版布局</h3>
|
||||
<p class="text-sm text-slate-400 mt-1">
|
||||
我们检测到您之前使用过旧版布局,是否要迁移您的会话数据?
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="bg-slate-900 rounded p-3 text-sm text-slate-300">
|
||||
<p class="font-medium mb-2">迁移内容:</p>
|
||||
<ul class="space-y-1 text-slate-400">
|
||||
<li>• 所有连接将被导入到会话树</li>
|
||||
<li>• 打开的终端和 SFTP 标签页将被保留</li>
|
||||
<li>• 您可以随时切换回旧版布局</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span>不再显示此提示</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 p-5 border-t border-slate-700">
|
||||
<button
|
||||
@click="handleMigrate"
|
||||
class="flex-1 px-4 py-2 rounded bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
立即迁移
|
||||
</button>
|
||||
<button
|
||||
@click="handleDismiss"
|
||||
class="flex-1 px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
稍后再说
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, provide } from 'vue'
|
||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||
import { useWorkspaceStore } from '../stores/workspace'
|
||||
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
|
||||
import SessionTreeNode from './SessionTreeNode.vue'
|
||||
import { FolderPlus } from 'lucide-vue-next'
|
||||
|
||||
@@ -13,6 +14,10 @@ const newFolderName = ref('')
|
||||
|
||||
const rootNodes = computed(() => treeStore.rootNodes)
|
||||
|
||||
// Provide drag-drop handlers to child components
|
||||
const dragHandlers = useTreeDragDrop()
|
||||
provide('dragHandlers', dragHandlers)
|
||||
|
||||
function handleNodeClick(nodeId: string) {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import type { SessionTreeNode as TreeNode } from '../types/sessionTree'
|
||||
@@ -29,22 +29,88 @@ const connection = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// Drag-drop functionality
|
||||
const dragHandlers = inject<any>('dragHandlers', null)
|
||||
|
||||
const isDragging = computed(() =>
|
||||
dragHandlers?.draggedNode.value?.id === props.node.id
|
||||
)
|
||||
|
||||
const isDropTarget = computed(() =>
|
||||
dragHandlers?.dropTarget.value?.id === props.node.id
|
||||
)
|
||||
|
||||
const dropPosition = computed(() =>
|
||||
isDropTarget.value ? dragHandlers?.dropPosition.value : null
|
||||
)
|
||||
|
||||
function handleClick() {
|
||||
emit('click', props.node.id)
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragEvent) {
|
||||
if (dragHandlers) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
dragHandlers.handleDragStart(props.node)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDragOver(event, props.node)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDragLeave()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDrop(event)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDragEnd()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded cursor-pointer hover:bg-slate-800 transition-colors"
|
||||
draggable="true"
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded cursor-pointer hover:bg-slate-800 transition-colors relative"
|
||||
:class="{
|
||||
'bg-slate-800 text-cyan-300': isSelected,
|
||||
'text-slate-300': !isSelected,
|
||||
'opacity-50': isDragging,
|
||||
'ring-2 ring-cyan-500': isDropTarget && dropPosition === 'inside',
|
||||
}"
|
||||
:style="{ paddingLeft: `${level * 16 + 8}px` }"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<!-- Drop indicator - before -->
|
||||
<div
|
||||
v-if="isDropTarget && dropPosition === 'before'"
|
||||
class="absolute top-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<!-- Drop indicator - after -->
|
||||
<div
|
||||
v-if="isDropTarget && dropPosition === 'after'"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="node.type === 'folder'"
|
||||
class="w-4 h-4 flex items-center justify-center"
|
||||
|
||||
Reference in New Issue
Block a user