diff --git a/backend/app/models/growth_path.py b/backend/app/models/growth_path.py
index 6ddbeac..217d594 100644
--- a/backend/app/models/growth_path.py
+++ b/backend/app/models/growth_path.py
@@ -86,6 +86,14 @@ class GrowthPathNode(BaseModel, SoftDeleteMixin):
estimated_days: Mapped[int] = mapped_column(
Integer, default=7, nullable=False, comment="预计学习天数"
)
+
+ # 画布位置(用于可视化编辑器)
+ position_x: Mapped[Optional[int]] = mapped_column(
+ Integer, nullable=True, default=0, comment="画布X坐标"
+ )
+ position_y: Mapped[Optional[int]] = mapped_column(
+ Integer, nullable=True, default=0, comment="画布Y坐标"
+ )
# 关联关系
growth_path: Mapped["GrowthPath"] = relationship( # noqa: F821
diff --git a/backend/app/schemas/growth_path.py b/backend/app/schemas/growth_path.py
index 8c1ecfd..ee3ac75 100644
--- a/backend/app/schemas/growth_path.py
+++ b/backend/app/schemas/growth_path.py
@@ -28,6 +28,8 @@ class NodeBase(BaseModel):
is_required: bool = Field(True, description="是否必修")
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
estimated_days: int = Field(7, description="预计学习天数")
+ position_x: Optional[int] = Field(0, description="画布X坐标")
+ position_y: Optional[int] = Field(0, description="画布Y坐标")
# =====================================================
diff --git a/frontend/src/api/manager/index.ts b/frontend/src/api/manager/index.ts
index b5e8eda..e307608 100644
--- a/frontend/src/api/manager/index.ts
+++ b/frontend/src/api/manager/index.ts
@@ -167,6 +167,8 @@ export interface CreateGrowthPathNode {
is_required: boolean
prerequisites?: number[]
estimated_days: number
+ position_x?: number // 画布X坐标
+ position_y?: number // 画布Y坐标
}
// 创建成长路径请求
diff --git a/frontend/src/views/manager/growth-path-management.vue b/frontend/src/views/manager/growth-path-management.vue
index e803c54..c85c63c 100644
--- a/frontend/src/views/manager/growth-path-management.vue
+++ b/frontend/src/views/manager/growth-path-management.vue
@@ -117,6 +117,13 @@
+
+
+
+ 操作提示:从左侧拖拽课程到画布 | 拖拽节点右侧圆点可连接到其他课程(箭头表示前置依赖) | 节点位置决定所属阶段
+
+
+
@@ -172,17 +179,22 @@
class="canvas-content"
@dragover.prevent
@drop="handleDrop"
+ @mousemove="handleCanvasMouseMove"
+ @mouseup="handleCanvasMouseUp"
ref="canvasRef"
>
-
-
+
+
-
{{ stage.name }}
+
+ {{ stage.name }}
+
@@ -191,15 +203,52 @@
拖拽课程到这里开始设计成长路径
+
+
+
-
-
-
@@ -261,33 +307,15 @@
总学时:
{{ totalDuration }}h
+
+ 连接数:
+ {{ connections.length }}
+
-
-
-
-
-
为课程 {{ currentNode?.title }} 设置前置课程:
-
选中的课程将作为当前课程的前置条件,用箭头连接显示
-
-
- {{ node.title }}
-
-
-
-
-
- 取消
- 确定
-
-
@@ -321,7 +349,6 @@ interface Course {
name?: string
title?: string
duration_hours?: number
- category?: string
}
interface PathNode {
@@ -332,7 +359,7 @@ interface PathNode {
estimated_days: number
x: number
y: number
- stage_name?: string
+ stage_name: string
}
interface Connection {
@@ -350,6 +377,9 @@ interface EditingPath {
is_active: boolean
}
+// ========== 常量 ==========
+const stageHeight = 200 // 每个阶段的高度
+
// ========== 状态 ==========
const loading = ref(false)
const saving = ref(false)
@@ -363,6 +393,7 @@ const filters = ref<{ position_id?: number }>({})
const editingPath = ref(null)
const courseSearch = ref('')
const canvasRef = ref()
+const canvasInnerRef = ref()
// 画布节点和连接
const pathNodes = ref([])
@@ -373,10 +404,11 @@ const selectedNode = ref(null)
const draggedNode = ref(null)
const dragOffset = ref({ x: 0, y: 0 })
-// 前置课程
-const dependencyDialogVisible = ref(false)
-const currentNode = ref(null)
-const selectedDependencies = ref([])
+// 连线绘制
+const isDrawingConnection = ref(false)
+const connectionStart = ref(null)
+const drawingLine = ref('')
+const mousePos = ref({ x: 0, y: 0 })
// 基础数据
const positions = ref([])
@@ -400,11 +432,26 @@ const totalDuration = computed(() => {
}, 0)
})
-const availableDependencies = computed(() =>
- pathNodes.value.filter(n => n.id !== currentNode.value?.id)
-)
+// ========== 工具方法 ==========
-// ========== 方法 ==========
+/**
+ * 根据Y坐标获取所属阶段
+ */
+const getStageAtY = (y: number): string => {
+ if (!editingPath.value) return ''
+ const stageIndex = Math.floor(y / stageHeight)
+ const clampedIndex = Math.max(0, Math.min(stageIndex, editingPath.value.stages.length - 1))
+ return editingPath.value.stages[clampedIndex]?.name || ''
+}
+
+/**
+ * 更新节点的阶段(根据位置)
+ */
+const updateNodeStage = (node: PathNode) => {
+ node.stage_name = getStageAtY(node.y)
+}
+
+// ========== 数据加载 ==========
const loadPositions = async () => {
try {
@@ -482,24 +529,26 @@ const handleEditPath = async (row: GrowthPathListItem) => {
is_active: detail.is_active,
}
- // 转换节点数据
- pathNodes.value = (detail.nodes || []).map((n, index) => ({
- id: `n${n.course_id}`,
- course_id: n.course_id,
- title: n.title,
- is_required: n.is_required,
- estimated_days: n.estimated_days || 1,
- stage_name: n.stage_name,
- // 根据stage和顺序计算位置
- x: 50 + (index % 3) * 220,
- y: 50 + Math.floor(index / 3) * 150,
- }))
+ // 转换节点数据 - 使用保存的位置,如果没有则自动布局
+ pathNodes.value = (detail.nodes || []).map((n: any, index: number) => {
+ const hasPosition = n.position_x !== undefined && n.position_x !== null && n.position_x > 0
+ return {
+ id: `n${n.course_id}`,
+ course_id: n.course_id,
+ title: n.title,
+ is_required: n.is_required,
+ estimated_days: n.estimated_days || 1,
+ stage_name: n.stage_name || editingPath.value!.stages[0]?.name || '入门阶段',
+ x: hasPosition ? n.position_x : 50 + (index % 3) * 220,
+ y: hasPosition ? n.position_y : 50 + Math.floor(index / 3) * stageHeight,
+ }
+ })
// 转换前置课程为连接线
connections.value = []
- ;(detail.nodes || []).forEach(n => {
+ ;(detail.nodes || []).forEach((n: any) => {
if (n.prerequisites && n.prerequisites.length > 0) {
- n.prerequisites.forEach(preId => {
+ n.prerequisites.forEach((preId: number) => {
connections.value.push({
from: `n${preId}`,
to: `n${n.course_id}`
@@ -534,7 +583,7 @@ const handleSavePath = async () => {
saving.value = true
try {
- // 转换节点数据
+ // 转换节点数据,包含位置信息
const nodes: CreateGrowthPathNode[] = pathNodes.value.map((node, index) => {
// 获取前置课程
const prerequisites = connections.value
@@ -548,11 +597,13 @@ const handleSavePath = async () => {
return {
course_id: node.course_id,
title: node.title,
- stage_name: node.stage_name || editingPath.value!.stages[0]?.name || '入门阶段',
+ stage_name: node.stage_name,
order_num: index + 1,
is_required: node.is_required,
estimated_days: node.estimated_days,
prerequisites,
+ position_x: Math.round(node.x),
+ position_y: Math.round(node.y),
}
})
@@ -628,9 +679,11 @@ const handleDrop = (event: DragEvent) => {
const rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop
- const x = event.clientX - rect.left + scrollLeft - 75
+ const x = event.clientX - rect.left + scrollLeft - 85
const y = event.clientY - rect.top + scrollTop - 40
+ const stageName = getStageAtY(Math.max(0, y))
+
const newNode: PathNode = {
id: `n${course.id}`,
course_id: course.id,
@@ -639,16 +692,23 @@ const handleDrop = (event: DragEvent) => {
estimated_days: Math.ceil((course.duration_hours || 2) / 2),
x: Math.max(0, x),
y: Math.max(0, y),
+ stage_name: stageName,
}
pathNodes.value.push(newNode)
- ElMessage.success('课程添加成功')
+ ElMessage.success(`已添加到"${stageName}"`)
}
const startNodeDrag = (event: MouseEvent, node: PathNode) => {
if (!canvasRef.value) return
+ // 检查是否点击的是连接点
+ const target = event.target as HTMLElement
+ if (target.closest('.connection-handle')) return
+
draggedNode.value = node
+ selectedNode.value = node
+
const rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop
@@ -658,27 +718,150 @@ const startNodeDrag = (event: MouseEvent, node: PathNode) => {
y: event.clientY - rect.top + scrollTop - node.y
}
- const handleMouseMove = (e: MouseEvent) => {
- if (!draggedNode.value || !canvasRef.value) return
+ document.addEventListener('mousemove', handleNodeMouseMove)
+ document.addEventListener('mouseup', handleNodeMouseUp)
+}
+
+const handleNodeMouseMove = (e: MouseEvent) => {
+ if (!draggedNode.value || !canvasRef.value) return
+
+ const rect = canvasRef.value.getBoundingClientRect()
+ const scrollLeft = canvasRef.value.scrollLeft
+ const scrollTop = canvasRef.value.scrollTop
+
+ draggedNode.value.x = Math.max(0, e.clientX - rect.left + scrollLeft - dragOffset.value.x)
+ draggedNode.value.y = Math.max(0, e.clientY - rect.top + scrollTop - dragOffset.value.y)
+
+ // 实时更新阶段
+ updateNodeStage(draggedNode.value)
+}
+
+const handleNodeMouseUp = () => {
+ if (draggedNode.value) {
+ // 最终更新阶段
+ updateNodeStage(draggedNode.value)
+ }
+ draggedNode.value = null
+ document.removeEventListener('mousemove', handleNodeMouseMove)
+ document.removeEventListener('mouseup', handleNodeMouseUp)
+}
+
+// ========== 连线绘制 ==========
+
+const startDrawConnection = (event: MouseEvent, node: PathNode) => {
+ event.preventDefault()
+ isDrawingConnection.value = true
+ connectionStart.value = node
+
+ if (canvasRef.value) {
const rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop
- draggedNode.value.x = Math.max(0, e.clientX - rect.left + scrollLeft - dragOffset.value.x)
- draggedNode.value.y = Math.max(0, e.clientY - rect.top + scrollTop - dragOffset.value.y)
+ mousePos.value = {
+ x: event.clientX - rect.left + scrollLeft,
+ y: event.clientY - rect.top + scrollTop
+ }
+ updateDrawingLine()
}
-
- const handleMouseUp = () => {
- draggedNode.value = null
- document.removeEventListener('mousemove', handleMouseMove)
- document.removeEventListener('mouseup', handleMouseUp)
- }
-
- document.addEventListener('mousemove', handleMouseMove)
- document.addEventListener('mouseup', handleMouseUp)
}
-const selectNode = (node: PathNode) => {
- selectedNode.value = selectedNode.value?.id === node.id ? null : node
+const handleCanvasMouseMove = (event: MouseEvent) => {
+ if (!isDrawingConnection.value || !canvasRef.value) return
+
+ const rect = canvasRef.value.getBoundingClientRect()
+ const scrollLeft = canvasRef.value.scrollLeft
+ const scrollTop = canvasRef.value.scrollTop
+
+ mousePos.value = {
+ x: event.clientX - rect.left + scrollLeft,
+ y: event.clientY - rect.top + scrollTop
+ }
+ updateDrawingLine()
+}
+
+const handleCanvasMouseUp = () => {
+ if (isDrawingConnection.value) {
+ // 取消连线
+ isDrawingConnection.value = false
+ connectionStart.value = null
+ drawingLine.value = ''
+ }
+}
+
+const endDrawConnection = (targetNode: PathNode) => {
+ if (!isDrawingConnection.value || !connectionStart.value) return
+ if (connectionStart.value.id === targetNode.id) return
+
+ // 检查是否已存在连接
+ const exists = connections.value.some(
+ c => c.from === connectionStart.value!.id && c.to === targetNode.id
+ )
+
+ if (exists) {
+ ElMessage.warning('该连接已存在')
+ } else {
+ // 检查是否会形成循环
+ const wouldCycle = checkCycle(connectionStart.value.id, targetNode.id)
+ if (wouldCycle) {
+ ElMessage.warning('不能形成循环依赖')
+ } else {
+ connections.value.push({
+ from: connectionStart.value.id,
+ to: targetNode.id
+ })
+ ElMessage.success('连接已建立')
+ }
+ }
+
+ isDrawingConnection.value = false
+ connectionStart.value = null
+ drawingLine.value = ''
+}
+
+const checkCycle = (fromId: string, toId: string): boolean => {
+ // 简单的循环检测:检查是否 toId 能到达 fromId
+ const visited = new Set()
+ const queue = [toId]
+
+ while (queue.length > 0) {
+ const current = queue.shift()!
+ if (current === fromId) return true
+ if (visited.has(current)) continue
+ visited.add(current)
+
+ // 找到所有从 current 出发的连接
+ const outgoing = connections.value.filter(c => c.from === current)
+ outgoing.forEach(c => queue.push(c.to))
+ }
+
+ return false
+}
+
+const updateDrawingLine = () => {
+ if (!connectionStart.value) return
+
+ const startX = connectionStart.value.x + 170 // 节点右侧
+ const startY = connectionStart.value.y + 45 // 节点中间
+ const endX = mousePos.value.x
+ const endY = mousePos.value.y
+
+ const midX = (startX + endX) / 2
+ drawingLine.value = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`
+}
+
+const getConnectionPath = (conn: Connection) => {
+ const fromNode = pathNodes.value.find(n => n.id === conn.from)
+ const toNode = pathNodes.value.find(n => n.id === conn.to)
+
+ if (!fromNode || !toNode) return ''
+
+ const x1 = fromNode.x + 170 // 从右侧出
+ const y1 = fromNode.y + 45
+ const x2 = toNode.x // 到左侧入
+ const y2 = toNode.y + 45
+
+ const midX = (x1 + x2) / 2
+ return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`
}
const handleNodeCommand = (command: string, node: PathNode) => {
@@ -686,13 +869,6 @@ const handleNodeCommand = (command: string, node: PathNode) => {
case 'required':
node.is_required = !node.is_required
break
- case 'dependency':
- currentNode.value = node
- selectedDependencies.value = connections.value
- .filter(c => c.to === node.id)
- .map(c => c.from)
- dependencyDialogVisible.value = true
- break
case 'delete':
deleteNode(node)
break
@@ -710,36 +886,6 @@ const deleteNode = (node: PathNode) => {
.catch(() => {})
}
-const saveDependencies = () => {
- if (!currentNode.value) return
-
- // 移除旧连接
- connections.value = connections.value.filter(c => c.to !== currentNode.value!.id)
-
- // 添加新连接
- selectedDependencies.value.forEach(fromId => {
- connections.value.push({ from: fromId, to: currentNode.value!.id })
- })
-
- dependencyDialogVisible.value = false
- ElMessage.success('前置课程设置成功')
-}
-
-const getConnectionPath = (conn: Connection) => {
- const fromNode = pathNodes.value.find(n => n.id === conn.from)
- const toNode = pathNodes.value.find(n => n.id === conn.to)
-
- if (!fromNode || !toNode) return ''
-
- const x1 = fromNode.x + 85
- const y1 = fromNode.y + 80
- const x2 = toNode.x + 85
- const y2 = toNode.y
-
- const midY = (y1 + y2) / 2
- return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`
-}
-
const clearCanvas = () => {
ElMessageBox.confirm('确定要清空画布吗?', '清空确认', { type: 'warning' })
.then(() => {
@@ -758,15 +904,26 @@ const autoLayout = () => {
const cols = 3
const xSpacing = 220
- const ySpacing = 150
- const startX = 50
- const startY = 50
+ const yOffset = 30
- pathNodes.value.forEach((node, index) => {
- const row = Math.floor(index / cols)
- const col = index % cols
- node.x = startX + col * xSpacing
- node.y = startY + row * ySpacing
+ // 按阶段分组
+ const stages = editingPath.value?.stages || []
+ stages.forEach((stage, stageIndex) => {
+ const stageNodes = pathNodes.value.filter(n => n.stage_name === stage.name)
+ stageNodes.forEach((node, nodeIndex) => {
+ const col = nodeIndex % cols
+ const row = Math.floor(nodeIndex / cols)
+ node.x = 50 + col * xSpacing
+ node.y = stageIndex * stageHeight + yOffset + row * 100
+ })
+ })
+
+ // 处理未分配阶段的节点
+ const unassigned = pathNodes.value.filter(n => !stages.some(s => s.name === n.stage_name))
+ unassigned.forEach((node, index) => {
+ node.x = 50 + (index % cols) * xSpacing
+ node.y = 30 + Math.floor(index / cols) * 100
+ node.stage_name = stages[0]?.name || '入门阶段'
})
ElMessage.success('自动布局完成')
@@ -834,7 +991,7 @@ onMounted(() => {
background: #fff;
border-radius: 8px;
padding: 12px 20px;
- margin-bottom: 16px;
+ margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
@@ -858,7 +1015,7 @@ onMounted(() => {
}
.course-library {
- width: 280px;
+ width: 260px;
display: flex;
flex-direction: column;
@@ -950,32 +1107,42 @@ onMounted(() => {
position: relative;
width: 100%;
min-width: 900px;
- min-height: 800px;
+ min-height: 700px;
background-image:
- linear-gradient(rgba(102, 126, 234, .08) 1px, transparent 1px),
- linear-gradient(90deg, rgba(102, 126, 234, .08) 1px, transparent 1px);
+ linear-gradient(rgba(102, 126, 234, .06) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(102, 126, 234, .06) 1px, transparent 1px);
background-size: 20px 20px;
}
- .stage-divider {
+ .stage-zone {
position: absolute;
left: 0;
right: 0;
- height: 1px;
- background: linear-gradient(90deg, transparent, #667eea 20%, #667eea 80%, transparent);
- opacity: 0.3;
+ border-bottom: 1px dashed rgba(102, 126, 234, 0.2);
+ transition: background 0.2s;
- .stage-label {
+ &.stage-highlight {
+ background: rgba(102, 126, 234, 0.05);
+ }
+
+ .stage-divider {
position: absolute;
- left: 16px;
- top: -10px;
- background: #fafafa;
- padding: 2px 10px;
- font-size: 12px;
- color: #667eea;
- font-weight: 500;
- border-radius: 10px;
- border: 1px solid rgba(102, 126, 234, 0.3);
+ top: 0;
+ left: 0;
+ right: 0;
+
+ .stage-label {
+ position: absolute;
+ left: 12px;
+ top: 8px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: #fff;
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ border-radius: 12px;
+ box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3);
+ }
}
}
@@ -996,7 +1163,7 @@ onMounted(() => {
.path-node {
position: absolute;
width: 170px;
- padding: 14px;
+ padding: 12px 14px;
background: #fff;
border: 2px solid #e4e7ed;
border-radius: 10px;
@@ -1004,10 +1171,12 @@ onMounted(() => {
transition: all 0.2s;
user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ z-index: 10;
&:hover {
- box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2);
+ box-shadow: 0 4px 16px rgba(102, 126, 234, 0.25);
border-color: #667eea;
+ z-index: 20;
}
&.is-required {
@@ -1020,11 +1189,25 @@ onMounted(() => {
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
+ &.is-dragging {
+ opacity: 0.8;
+ z-index: 100;
+ }
+
+ &.can-connect {
+ .connection-handle.input {
+ .handle-dot {
+ transform: scale(1.5);
+ background: #67c23a;
+ }
+ }
+ }
+
.node-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
- margin-bottom: 10px;
+ margin-bottom: 8px;
.node-title {
font-size: 13px;
@@ -1044,11 +1227,54 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
- .node-duration {
- font-size: 12px;
+ .node-stage {
+ font-size: 11px;
color: #909399;
}
}
+
+ .connection-handle {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: crosshair;
+
+ &.output {
+ right: -10px;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ &.input {
+ left: -10px;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ &.can-drop .handle-dot {
+ transform: scale(1.5);
+ background: #67c23a;
+ box-shadow: 0 0 8px rgba(103, 194, 58, 0.5);
+ }
+
+ .handle-dot {
+ width: 12px;
+ height: 12px;
+ background: #667eea;
+ border-radius: 50%;
+ border: 2px solid #fff;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ transition: all 0.2s;
+
+ &:hover {
+ transform: scale(1.3);
+ background: #f56c6c;
+ }
+ }
+ }
}
.connections {
@@ -1058,7 +1284,16 @@ onMounted(() => {
width: 100%;
height: 100%;
pointer-events: none;
- z-index: 1;
+ z-index: 5;
+
+ .connection-line {
+ transition: stroke 0.2s;
+
+ &:hover {
+ stroke: #f56c6c;
+ stroke-width: 3;
+ }
+ }
}
}
@@ -1091,26 +1326,6 @@ onMounted(() => {
}
}
}
-
- .dependency-content {
- p {
- margin-bottom: 12px;
- font-size: 14px;
- color: #333;
- }
-
- .dependency-hint {
- font-size: 12px;
- color: #909399;
- margin-bottom: 16px;
- }
-
- .el-checkbox-group {
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
- }
}
@media (max-width: 1024px) {
@@ -1120,7 +1335,7 @@ onMounted(() => {
.course-library {
width: 100%;
- max-height: 250px;
+ max-height: 200px;
}
}
}