From 973ce53bf327c370a38b69c8fe0d2f00f043168e Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Tue, 3 Feb 2026 14:55:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=88=90=E9=95=BF?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=94=BB=E5=B8=83=E8=AE=BE=E8=AE=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 添加 position_x, position_y 字段保存节点位置 前端: - 支持从节点右侧圆点拖拽出箭头连接到其他课程 - 自动根据节点Y坐标识别所属阶段 - 保存并恢复节点位置,不再重置 - 阶段区域高亮显示 - 循环依赖检测 Co-authored-by: Cursor --- backend/app/models/growth_path.py | 8 + backend/app/schemas/growth_path.py | 2 + frontend/src/api/manager/index.ts | 2 + .../views/manager/growth-path-management.vue | 585 ++++++++++++------ 4 files changed, 412 insertions(+), 185 deletions(-) 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 @@

拖拽课程到这里开始设计成长路径

+ + + + + + + + + + + + + + + +
{{ node.title }} @@ -212,7 +261,6 @@ {{ node.is_required ? '设为选修' : '设为必修' }} - 设置前置课程 删除节点 @@ -222,27 +270,25 @@ {{ node.is_required ? '必修' : '选修' }} - {{ node.estimated_days || 1 }}天 + {{ node.stage_name }} +
+ +
+
+
+ +
+
- - - - - - - - - -
@@ -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; } } }