diff --git a/frontend/src/views/manager/growth-path-management.vue b/frontend/src/views/manager/growth-path-management.vue index 228d7b7..cce4d59 100644 --- a/frontend/src/views/manager/growth-path-management.vue +++ b/frontend/src/views/manager/growth-path-management.vue @@ -334,29 +334,44 @@ :class="{ 'is-dragging-over': isDraggingOver }" @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" - @drop="handleDrop" + @drop="handleDrop" > {{ stage.name }} {{ getStageNodes(stage.name).length }} 门 - + - + - + {{ node.title }} @@ -367,16 +382,17 @@ style="cursor: pointer" > {{ node.is_required ? '必修' : '选修' }} - + {{ node.estimated_days || 1 }}天 - - + + - - + + - 拖拽或点击课程添加到此阶段 - - - + + 拖拽课程到此阶段 + + + @@ -494,6 +512,13 @@ const courseCategory = ref('') const isDragging = ref(false) const isDraggingOver = ref(false) +// 节点拖拽排序状态 +const draggingNode = ref(null) +const draggingFromStage = ref('') +const dragOverNode = ref(null) +const dragOverPosition = ref<'top' | 'bottom'>('bottom') +const dragOverStage = ref('') + // 基础数据 const positions = ref([]) const courses = ref([]) @@ -942,6 +967,255 @@ const removeNode = (node: CreateGrowthPathNode) => { } } +/** + * 节点阶段变更时重新排序 + */ +const handleNodeStageChange = (node: CreateGrowthPathNode) => { + // 阶段变更后自动调整到该阶段的末尾 + reorderNodes() +} + +/** + * 重新排序节点(按阶段分组) + */ +const reorderNodes = () => { + if (!editingPath.value) return + // 按阶段顺序重新排列 + const stageOrder = editingPath.value.stages.map(s => s.name) + editingPath.value.nodes.sort((a, b) => { + const stageIndexA = stageOrder.indexOf(a.stage_name) + const stageIndexB = stageOrder.indexOf(b.stage_name) + if (stageIndexA !== stageIndexB) { + return stageIndexA - stageIndexB + } + return (a.order_num || 0) - (b.order_num || 0) + }) + // 更新 order_num + editingPath.value.nodes.forEach((node, index) => { + node.order_num = index + 1 + }) +} + +// ========== 节点拖拽排序 ========== + +/** + * 节点开始拖拽 + */ +const handleNodeDragStart = (event: DragEvent, node: CreateGrowthPathNode, stageName: string) => { + draggingNode.value = node + draggingFromStage.value = stageName + event.dataTransfer!.effectAllowed = 'move' + event.dataTransfer!.setData('node', JSON.stringify(node)) + // 添加拖拽样式 + const target = event.target as HTMLElement + setTimeout(() => { + target.classList.add('is-dragging') + }, 0) +} + +/** + * 节点拖拽结束 + */ +const handleNodeDragEnd = () => { + draggingNode.value = null + draggingFromStage.value = '' + dragOverNode.value = null + dragOverPosition.value = 'bottom' + dragOverStage.value = '' +} + +/** + * 节点拖拽经过另一个节点 + */ +const handleNodeDragOver = (event: DragEvent, targetNode: CreateGrowthPathNode) => { + if (!draggingNode.value || draggingNode.value.course_id === targetNode.course_id) return + + event.preventDefault() + dragOverNode.value = targetNode + + // 判断是放在目标节点的上方还是下方 + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() + const midY = rect.top + rect.height / 2 + dragOverPosition.value = event.clientY < midY ? 'top' : 'bottom' +} + +/** + * 节点拖拽离开 + */ +const handleNodeDragLeave = () => { + dragOverNode.value = null +} + +/** + * 节点放置到另一个节点上 + */ +const handleNodeDropOnNode = (event: DragEvent, targetNode: CreateGrowthPathNode, targetStage: string) => { + event.preventDefault() + + if (!draggingNode.value || !editingPath.value) return + if (draggingNode.value.course_id === targetNode.course_id) return + + // 找到拖拽节点的索引 + const fromIndex = editingPath.value.nodes.findIndex(n => n.course_id === draggingNode.value!.course_id) + if (fromIndex === -1) return + + // 从原位置移除 + const [movedNode] = editingPath.value.nodes.splice(fromIndex, 1) + + // 更新阶段 + movedNode.stage_name = targetStage + + // 找到目标节点的新索引(因为可能已经移除了一个元素) + const toIndex = editingPath.value.nodes.findIndex(n => n.course_id === targetNode.course_id) + + // 根据放置位置插入 + if (dragOverPosition.value === 'top') { + editingPath.value.nodes.splice(toIndex, 0, movedNode) + } else { + editingPath.value.nodes.splice(toIndex + 1, 0, movedNode) + } + + // 重新计算 order_num + editingPath.value.nodes.forEach((node, index) => { + node.order_num = index + 1 + }) + + // 清理状态 + handleNodeDragEnd() + ElMessage.success('排序已更新') +} + +/** + * 阶段区域拖拽经过 + */ +const handleStageDragOver = (event: DragEvent, stageName: string) => { + event.preventDefault() + dragOverStage.value = stageName +} + +/** + * 阶段区域拖拽离开 + */ +const handleStageDragLeave = (event: DragEvent) => { + // 检查是否真的离开了阶段区域 + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() + const x = event.clientX + const y = event.clientY + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + dragOverStage.value = '' + } +} + +/** + * 节点放置到阶段区域(空白处) + */ +const handleNodeDrop = (event: DragEvent, targetStage: string) => { + event.preventDefault() + + // 优先处理从课程库拖入的课程 + const courseData = event.dataTransfer!.getData('course') + if (courseData) { + const course = JSON.parse(courseData) as Course + handleAddCourseToStage(course, targetStage) + dragOverStage.value = '' + return + } + + // 处理节点排序 + if (!draggingNode.value || !editingPath.value) { + dragOverStage.value = '' + return + } + + // 移动节点到目标阶段末尾 + const fromIndex = editingPath.value.nodes.findIndex(n => n.course_id === draggingNode.value!.course_id) + if (fromIndex === -1) return + + const [movedNode] = editingPath.value.nodes.splice(fromIndex, 1) + movedNode.stage_name = targetStage + + // 找到该阶段最后一个节点的位置,插入其后 + const stageNodes = editingPath.value.nodes.filter(n => n.stage_name === targetStage) + if (stageNodes.length > 0) { + const lastNode = stageNodes[stageNodes.length - 1] + const lastIndex = editingPath.value.nodes.findIndex(n => n.course_id === lastNode.course_id) + editingPath.value.nodes.splice(lastIndex + 1, 0, movedNode) + } else { + // 该阶段为空,找到下一个阶段的第一个节点位置 + const stageOrder = editingPath.value.stages.map(s => s.name) + const targetStageIndex = stageOrder.indexOf(targetStage) + let insertIndex = editingPath.value.nodes.length + + for (let i = targetStageIndex + 1; i < stageOrder.length; i++) { + const nextStageFirstNode = editingPath.value.nodes.find(n => n.stage_name === stageOrder[i]) + if (nextStageFirstNode) { + insertIndex = editingPath.value.nodes.indexOf(nextStageFirstNode) + break + } + } + editingPath.value.nodes.splice(insertIndex, 0, movedNode) + } + + // 重新计算 order_num + editingPath.value.nodes.forEach((node, index) => { + node.order_num = index + 1 + }) + + handleNodeDragEnd() + ElMessage.success('已移动到' + targetStage) +} + +/** + * 添加课程到指定阶段 + */ +const handleAddCourseToStage = (course: Course, stageName: string) => { + if (!editingPath.value) return + if (isNodeAdded(course.id)) { + ElMessage.warning('该课程已添加') + return + } + + // 找到该阶段最后一个节点的位置 + const stageNodes = editingPath.value.nodes.filter(n => n.stage_name === stageName) + let insertIndex = editingPath.value.nodes.length + + if (stageNodes.length > 0) { + const lastNode = stageNodes[stageNodes.length - 1] + insertIndex = editingPath.value.nodes.indexOf(lastNode) + 1 + } else { + // 该阶段为空,找到正确的插入位置 + const stageOrder = editingPath.value.stages.map(s => s.name) + const targetStageIndex = stageOrder.indexOf(stageName) + + for (let i = targetStageIndex + 1; i < stageOrder.length; i++) { + const nextStageFirstNode = editingPath.value.nodes.find(n => n.stage_name === stageOrder[i]) + if (nextStageFirstNode) { + insertIndex = editingPath.value.nodes.indexOf(nextStageFirstNode) + break + } + } + } + + const newNode: CreateGrowthPathNode = { + course_id: course.id, + stage_name: stageName, + title: course.name || course.title || '', + description: course.description, + order_num: insertIndex + 1, + is_required: true, + estimated_days: Math.ceil((course.duration_hours || course.estimatedDuration || 2) / 2), + } + + editingPath.value.nodes.splice(insertIndex, 0, newNode) + + // 重新计算 order_num + editingPath.value.nodes.forEach((node, index) => { + node.order_num = index + 1 + }) + + ElMessage.success(`已添加到${stageName}`) +} + // ========== 生命周期 ========== onMounted(() => { loadPositions() @@ -1316,6 +1590,14 @@ onMounted(() => { .stage-section { margin-bottom: 16px; + transition: all 0.2s ease; + + &.stage-drag-over { + .stage-nodes { + background: #ecf5ff; + border-color: #667eea; + } + } .stage-header { display: flex; @@ -1335,64 +1617,135 @@ onMounted(() => { border-radius: 0 0 6px 6px; padding: 8px; min-height: 50px; + transition: all 0.2s ease; .node-item { display: flex; align-items: center; gap: 8px; padding: 10px; - background: #fff; + background: #fff; border: 1px solid #ebeef5; border-radius: 6px; margin-bottom: 8px; + cursor: grab; + transition: all 0.2s ease; + position: relative; &:last-child { margin-bottom: 0; } + &:hover { + border-color: #667eea; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); + } + + &:active { + cursor: grabbing; + } + + &.is-dragging { + opacity: 0.5; + background: #f5f7fa; + border-style: dashed; + } + + &.drag-over-top { + &::before { + content: ''; + position: absolute; + top: -4px; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #667eea, #764ba2); + border-radius: 2px; + animation: pulse-line 1s infinite; + } + } + + &.drag-over-bottom { + &::after { + content: ''; + position: absolute; + bottom: -4px; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #667eea, #764ba2); + border-radius: 2px; + animation: pulse-line 1s infinite; + } + } + .node-drag-handle { color: #c0c4cc; cursor: move; + transition: color 0.2s; + + &:hover { + color: #667eea; + } } .node-content { flex: 1; min-width: 0; - .node-title { + .node-title { display: block; - font-weight: 500; - color: #333; + font-weight: 500; + color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .node-meta { - display: flex; - align-items: center; + display: flex; + align-items: center; gap: 8px; margin-top: 4px; - font-size: 12px; - color: #909399; + font-size: 12px; + color: #909399; } } .node-actions { - display: flex; - align-items: center; + display: flex; + align-items: center; gap: 8px; } } .stage-empty { text-align: center; - padding: 16px; + padding: 20px 16px; color: #909399; font-size: 13px; + border: 2px dashed #e4e7ed; + border-radius: 6px; + margin: 4px 0; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + &.stage-empty-active { + background: #ecf5ff; + border-color: #667eea; + color: #667eea; + } + } } } - } + + @keyframes pulse-line { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } } } }