feat: 成长路径管理添加拖拽排序功能
All checks were successful
continuous-integration/drone/push Build is passing

- 已选课程支持拖拽调整顺序
- 支持跨阶段拖拽移动课程
- 拖拽时显示视觉反馈(高亮线条)
- 拖拽到空阶段时显示占位提示
- 自动更新课程排序编号

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuliang_guo
2026-02-03 14:34:37 +08:00
parent e110067840
commit 20905b72cc

View File

@@ -334,29 +334,44 @@
:class="{ 'is-dragging-over': isDraggingOver }" :class="{ 'is-dragging-over': isDraggingOver }"
@dragover.prevent="handleDragOver" @dragover.prevent="handleDragOver"
@dragleave="handleDragLeave" @dragleave="handleDragLeave"
@drop="handleDrop" @drop="handleDrop"
> >
<template v-if="editingPath.nodes && editingPath.nodes.length > 0"> <template v-if="editingPath.nodes && editingPath.nodes.length > 0">
<div <div
v-for="(stage, stageIndex) in editingPath.stages" v-for="(stage, stageIndex) in editingPath.stages"
:key="stageIndex" :key="stageIndex"
class="stage-section" class="stage-section"
:class="{ 'stage-drag-over': dragOverStage === stage.name }"
@dragover.prevent="handleStageDragOver($event, stage.name)"
@dragleave="handleStageDragLeave($event)"
@drop.stop="handleNodeDrop($event, stage.name)"
> >
<div class="stage-header"> <div class="stage-header">
<span>{{ stage.name }}</span> <span>{{ stage.name }}</span>
<el-tag size="small" type="info"> <el-tag size="small" type="info">
{{ getStageNodes(stage.name).length }} {{ getStageNodes(stage.name).length }}
</el-tag> </el-tag>
</div> </div>
<div class="stage-nodes"> <div class="stage-nodes">
<div <div
v-for="(node, nodeIndex) in getStageNodes(stage.name)" v-for="(node, nodeIndex) in getStageNodes(stage.name)"
:key="node.course_id" :key="node.course_id"
class="node-item" class="node-item"
:class="{
'is-dragging': draggingNode?.course_id === node.course_id,
'drag-over-top': dragOverNode?.course_id === node.course_id && dragOverPosition === 'top',
'drag-over-bottom': dragOverNode?.course_id === node.course_id && dragOverPosition === 'bottom'
}"
draggable="true"
@dragstart="handleNodeDragStart($event, node, stage.name)"
@dragend="handleNodeDragEnd"
@dragover.prevent="handleNodeDragOver($event, node)"
@dragleave="handleNodeDragLeave"
@drop.stop="handleNodeDropOnNode($event, node, stage.name)"
> >
<div class="node-drag-handle"> <div class="node-drag-handle" @mousedown.stop>
<el-icon><Rank /></el-icon> <el-icon><Rank /></el-icon>
</div> </div>
<div class="node-content"> <div class="node-content">
<span class="node-title">{{ node.title }}</span> <span class="node-title">{{ node.title }}</span>
<div class="node-meta"> <div class="node-meta">
@@ -367,16 +382,17 @@
style="cursor: pointer" style="cursor: pointer"
> >
{{ node.is_required ? '必修' : '选修' }} {{ node.is_required ? '必修' : '选修' }}
</el-tag> </el-tag>
<span>{{ node.estimated_days || 1 }}</span> <span>{{ node.estimated_days || 1 }}</span>
</div> </div>
</div> </div>
<div class="node-actions"> <div class="node-actions">
<el-select <el-select
v-model="node.stage_name" v-model="node.stage_name"
size="small" size="small"
style="width: 90px" style="width: 90px"
placeholder="阶段" placeholder="阶段"
@change="handleNodeStageChange(node)"
> >
<el-option <el-option
v-for="s in editingPath.stages" v-for="s in editingPath.stages"
@@ -393,16 +409,18 @@
> >
<el-icon><Delete /></el-icon> <el-icon><Delete /></el-icon>
</el-button> </el-button>
</div> </div>
</div> </div>
<div <div
v-if="getStageNodes(stage.name).length === 0" v-if="getStageNodes(stage.name).length === 0"
class="stage-empty" class="stage-empty"
:class="{ 'stage-empty-active': dragOverStage === stage.name }"
> >
拖拽或点击课程添加到此阶段 <el-icon><Plus /></el-icon>
</div> 拖拽课程到此阶段
</div> </div>
</div> </div>
</div>
</template> </template>
<el-empty v-else description="请从左侧添加课程" :image-size="60"> <el-empty v-else description="请从左侧添加课程" :image-size="60">
<template #description> <template #description>
@@ -494,6 +512,13 @@ const courseCategory = ref('')
const isDragging = ref(false) const isDragging = ref(false)
const isDraggingOver = ref(false) const isDraggingOver = ref(false)
// 节点拖拽排序状态
const draggingNode = ref<CreateGrowthPathNode | null>(null)
const draggingFromStage = ref<string>('')
const dragOverNode = ref<CreateGrowthPathNode | null>(null)
const dragOverPosition = ref<'top' | 'bottom'>('bottom')
const dragOverStage = ref<string>('')
// 基础数据 // 基础数据
const positions = ref<Position[]>([]) const positions = ref<Position[]>([])
const courses = ref<Course[]>([]) const courses = ref<Course[]>([])
@@ -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(() => { onMounted(() => {
loadPositions() loadPositions()
@@ -1316,6 +1590,14 @@ onMounted(() => {
.stage-section { .stage-section {
margin-bottom: 16px; margin-bottom: 16px;
transition: all 0.2s ease;
&.stage-drag-over {
.stage-nodes {
background: #ecf5ff;
border-color: #667eea;
}
}
.stage-header { .stage-header {
display: flex; display: flex;
@@ -1335,64 +1617,135 @@ onMounted(() => {
border-radius: 0 0 6px 6px; border-radius: 0 0 6px 6px;
padding: 8px; padding: 8px;
min-height: 50px; min-height: 50px;
transition: all 0.2s ease;
.node-item { .node-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
background: #fff; background: #fff;
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
border-radius: 6px; border-radius: 6px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: grab;
transition: all 0.2s ease;
position: relative;
&:last-child { &:last-child {
margin-bottom: 0; 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 { .node-drag-handle {
color: #c0c4cc; color: #c0c4cc;
cursor: move; cursor: move;
transition: color 0.2s;
&:hover {
color: #667eea;
}
} }
.node-content { .node-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
.node-title { .node-title {
display: block; display: block;
font-weight: 500; font-weight: 500;
color: #333; color: #333;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.node-meta { .node-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
} }
} }
.node-actions { .node-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
} }
.stage-empty { .stage-empty {
text-align: center; text-align: center;
padding: 16px; padding: 20px 16px;
color: #909399; color: #909399;
font-size: 13px; 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; }
}
} }
} }
} }