feat: 完善成长路径画布设计器
All checks were successful
continuous-integration/drone/push Build is passing

后端:
- 添加 position_x, position_y 字段保存节点位置

前端:
- 支持从节点右侧圆点拖拽出箭头连接到其他课程
- 自动根据节点Y坐标识别所属阶段
- 保存并恢复节点位置,不再重置
- 阶段区域高亮显示
- 循环依赖检测

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuliang_guo
2026-02-03 14:55:01 +08:00
parent 9c916195c6
commit 973ce53bf3
4 changed files with 412 additions and 185 deletions

View File

@@ -87,6 +87,14 @@ class GrowthPathNode(BaseModel, SoftDeleteMixin):
Integer, default=7, nullable=False, comment="预计学习天数" 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 growth_path: Mapped["GrowthPath"] = relationship( # noqa: F821
"GrowthPath", back_populates="nodes" "GrowthPath", back_populates="nodes"

View File

@@ -28,6 +28,8 @@ class NodeBase(BaseModel):
is_required: bool = Field(True, description="是否必修") is_required: bool = Field(True, description="是否必修")
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs") prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
estimated_days: int = Field(7, description="预计学习天数") estimated_days: int = Field(7, description="预计学习天数")
position_x: Optional[int] = Field(0, description="画布X坐标")
position_y: Optional[int] = Field(0, description="画布Y坐标")
# ===================================================== # =====================================================

View File

@@ -167,6 +167,8 @@ export interface CreateGrowthPathNode {
is_required: boolean is_required: boolean
prerequisites?: number[] prerequisites?: number[]
estimated_days: number estimated_days: number
position_x?: number // 画布X坐标
position_y?: number // 画布Y坐标
} }
// 创建成长路径请求 // 创建成长路径请求

View File

@@ -117,6 +117,13 @@
</el-form> </el-form>
</div> </div>
<!-- 操作提示 -->
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
<template #title>
<span>操作提示从左侧拖拽课程到画布 | 拖拽节点右侧圆点可连接到其他课程箭头表示前置依赖 | 节点位置决定所属阶段</span>
</template>
</el-alert>
<div class="path-designer"> <div class="path-designer">
<!-- 左侧课程库 --> <!-- 左侧课程库 -->
<div class="course-library card"> <div class="course-library card">
@@ -172,17 +179,22 @@
class="canvas-content" class="canvas-content"
@dragover.prevent @dragover.prevent
@drop="handleDrop" @drop="handleDrop"
@mousemove="handleCanvasMouseMove"
@mouseup="handleCanvasMouseUp"
ref="canvasRef" ref="canvasRef"
> >
<div class="canvas-inner"> <div class="canvas-inner" ref="canvasInnerRef">
<!-- 阶段分隔线 --> <!-- 阶段分隔线和区域 -->
<div <div
class="stage-divider"
v-for="(stage, index) in editingPath.stages" v-for="(stage, index) in editingPath.stages"
:key="stage.name" :key="stage.name"
:style="{ top: (index * 200 + 100) + 'px' }" class="stage-zone"
:style="{ top: (index * stageHeight) + 'px', height: stageHeight + 'px' }"
:class="{ 'stage-highlight': getStageAtY(draggedNode?.y || 0) === stage.name }"
> >
<span class="stage-label">{{ stage.name }}</span> <div class="stage-divider">
<span class="stage-label">{{ stage.name }}</span>
</div>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
@@ -191,15 +203,52 @@
<p>拖拽课程到这里开始设计成长路径</p> <p>拖拽课程到这里开始设计成长路径</p>
</div> </div>
<!-- 连接线箭头 -->
<svg class="connections">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#667eea" />
</marker>
<marker id="arrowhead-drawing" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#f56c6c" />
</marker>
</defs>
<!-- 已建立的连接 -->
<path
v-for="conn in connections"
:key="`${conn.from}-${conn.to}`"
:d="getConnectionPath(conn)"
stroke="#667eea"
stroke-width="2"
fill="none"
marker-end="url(#arrowhead)"
class="connection-line"
/>
<!-- 正在绘制的连接线 -->
<path
v-if="isDrawingConnection && drawingLine"
:d="drawingLine"
stroke="#f56c6c"
stroke-width="2"
stroke-dasharray="5,5"
fill="none"
marker-end="url(#arrowhead-drawing)"
/>
</svg>
<!-- 路径节点 --> <!-- 路径节点 -->
<div <div
v-for="node in pathNodes" v-for="node in pathNodes"
:key="node.id" :key="node.id"
class="path-node" class="path-node"
:class="{ 'is-required': node.is_required, 'is-selected': selectedNode?.id === node.id }" :class="{
'is-required': node.is_required,
'is-selected': selectedNode?.id === node.id,
'is-dragging': draggedNode?.id === node.id,
'can-connect': isDrawingConnection && connectionStart?.id !== node.id
}"
:style="{ left: node.x + 'px', top: node.y + 'px' }" :style="{ left: node.x + 'px', top: node.y + 'px' }"
@mousedown="startNodeDrag($event, node)" @mousedown.stop="startNodeDrag($event, node)"
@click.stop="selectNode(node)"
> >
<div class="node-header"> <div class="node-header">
<span class="node-title">{{ node.title }}</span> <span class="node-title">{{ node.title }}</span>
@@ -212,7 +261,6 @@
<el-dropdown-item command="required"> <el-dropdown-item command="required">
{{ node.is_required ? '设为选修' : '设为必修' }} {{ node.is_required ? '设为选修' : '设为必修' }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item command="dependency">设置前置课程</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除节点</el-dropdown-item> <el-dropdown-item command="delete" divided>删除节点</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -222,27 +270,25 @@
<el-tag size="small" :type="node.is_required ? 'danger' : ''"> <el-tag size="small" :type="node.is_required ? 'danger' : ''">
{{ node.is_required ? '必修' : '选修' }} {{ node.is_required ? '必修' : '选修' }}
</el-tag> </el-tag>
<span class="node-duration">{{ node.estimated_days || 1 }}</span> <span class="node-stage">{{ node.stage_name }}</span>
</div>
<!-- 连接点右侧拖出箭头 -->
<div
class="connection-handle output"
@mousedown.stop="startDrawConnection($event, node)"
title="拖拽到其他课程建立前置关系"
>
<div class="handle-dot"></div>
</div>
<!-- 连接点左侧接收箭头 -->
<div
class="connection-handle input"
@mouseup.stop="endDrawConnection(node)"
:class="{ 'can-drop': isDrawingConnection && connectionStart?.id !== node.id }"
>
<div class="handle-dot"></div>
</div> </div>
</div> </div>
<!-- 连接线箭头 -->
<svg class="connections" v-if="connections.length > 0">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#667eea" />
</marker>
</defs>
<path
v-for="conn in connections"
:key="`${conn.from}-${conn.to}`"
:d="getConnectionPath(conn)"
stroke="#667eea"
stroke-width="2"
fill="none"
marker-end="url(#arrowhead)"
/>
</svg>
</div> </div>
</div> </div>
@@ -261,33 +307,15 @@
<span class="stat-label">总学时</span> <span class="stat-label">总学时</span>
<span class="stat-value">{{ totalDuration }}h</span> <span class="stat-value">{{ totalDuration }}h</span>
</div> </div>
<div class="stat-item">
<span class="stat-label">连接数</span>
<span class="stat-value">{{ connections.length }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- 设置前置课程弹窗 -->
<el-dialog v-model="dependencyDialogVisible" title="设置前置课程" width="500px">
<div class="dependency-content">
<p>为课程 <strong>{{ currentNode?.title }}</strong> 设置前置课程</p>
<p class="dependency-hint">选中的课程将作为当前课程的前置条件用箭头连接显示</p>
<el-checkbox-group v-model="selectedDependencies">
<el-checkbox
v-for="node in availableDependencies"
:key="node.id"
:label="node.id"
>
{{ node.title }}
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="availableDependencies.length === 0" description="暂无其他课程可选" :image-size="60" />
</div>
<template #footer>
<el-button @click="dependencyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveDependencies">确定</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -321,7 +349,6 @@ interface Course {
name?: string name?: string
title?: string title?: string
duration_hours?: number duration_hours?: number
category?: string
} }
interface PathNode { interface PathNode {
@@ -332,7 +359,7 @@ interface PathNode {
estimated_days: number estimated_days: number
x: number x: number
y: number y: number
stage_name?: string stage_name: string
} }
interface Connection { interface Connection {
@@ -350,6 +377,9 @@ interface EditingPath {
is_active: boolean is_active: boolean
} }
// ========== 常量 ==========
const stageHeight = 200 // 每个阶段的高度
// ========== 状态 ========== // ========== 状态 ==========
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
@@ -363,6 +393,7 @@ const filters = ref<{ position_id?: number }>({})
const editingPath = ref<EditingPath | null>(null) const editingPath = ref<EditingPath | null>(null)
const courseSearch = ref('') const courseSearch = ref('')
const canvasRef = ref<HTMLElement>() const canvasRef = ref<HTMLElement>()
const canvasInnerRef = ref<HTMLElement>()
// 画布节点和连接 // 画布节点和连接
const pathNodes = ref<PathNode[]>([]) const pathNodes = ref<PathNode[]>([])
@@ -373,10 +404,11 @@ const selectedNode = ref<PathNode | null>(null)
const draggedNode = ref<PathNode | null>(null) const draggedNode = ref<PathNode | null>(null)
const dragOffset = ref({ x: 0, y: 0 }) const dragOffset = ref({ x: 0, y: 0 })
// 前置课程 // 连线绘制
const dependencyDialogVisible = ref(false) const isDrawingConnection = ref(false)
const currentNode = ref<PathNode | null>(null) const connectionStart = ref<PathNode | null>(null)
const selectedDependencies = ref<string[]>([]) const drawingLine = ref('')
const mousePos = ref({ x: 0, y: 0 })
// 基础数据 // 基础数据
const positions = ref<Position[]>([]) const positions = ref<Position[]>([])
@@ -400,11 +432,26 @@ const totalDuration = computed(() => {
}, 0) }, 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 () => { const loadPositions = async () => {
try { try {
@@ -482,24 +529,26 @@ const handleEditPath = async (row: GrowthPathListItem) => {
is_active: detail.is_active, is_active: detail.is_active,
} }
// 转换节点数据 // 转换节点数据 - 使用保存的位置,如果没有则自动布局
pathNodes.value = (detail.nodes || []).map((n, index) => ({ pathNodes.value = (detail.nodes || []).map((n: any, index: number) => {
id: `n${n.course_id}`, const hasPosition = n.position_x !== undefined && n.position_x !== null && n.position_x > 0
course_id: n.course_id, return {
title: n.title, id: `n${n.course_id}`,
is_required: n.is_required, course_id: n.course_id,
estimated_days: n.estimated_days || 1, title: n.title,
stage_name: n.stage_name, is_required: n.is_required,
// 根据stage和顺序计算位置 estimated_days: n.estimated_days || 1,
x: 50 + (index % 3) * 220, stage_name: n.stage_name || editingPath.value!.stages[0]?.name || '入门阶段',
y: 50 + Math.floor(index / 3) * 150, x: hasPosition ? n.position_x : 50 + (index % 3) * 220,
})) y: hasPosition ? n.position_y : 50 + Math.floor(index / 3) * stageHeight,
}
})
// 转换前置课程为连接线 // 转换前置课程为连接线
connections.value = [] connections.value = []
;(detail.nodes || []).forEach(n => { ;(detail.nodes || []).forEach((n: any) => {
if (n.prerequisites && n.prerequisites.length > 0) { if (n.prerequisites && n.prerequisites.length > 0) {
n.prerequisites.forEach(preId => { n.prerequisites.forEach((preId: number) => {
connections.value.push({ connections.value.push({
from: `n${preId}`, from: `n${preId}`,
to: `n${n.course_id}` to: `n${n.course_id}`
@@ -534,7 +583,7 @@ const handleSavePath = async () => {
saving.value = true saving.value = true
try { try {
// 转换节点数据 // 转换节点数据,包含位置信息
const nodes: CreateGrowthPathNode[] = pathNodes.value.map((node, index) => { const nodes: CreateGrowthPathNode[] = pathNodes.value.map((node, index) => {
// 获取前置课程 // 获取前置课程
const prerequisites = connections.value const prerequisites = connections.value
@@ -548,11 +597,13 @@ const handleSavePath = async () => {
return { return {
course_id: node.course_id, course_id: node.course_id,
title: node.title, title: node.title,
stage_name: node.stage_name || editingPath.value!.stages[0]?.name || '入门阶段', stage_name: node.stage_name,
order_num: index + 1, order_num: index + 1,
is_required: node.is_required, is_required: node.is_required,
estimated_days: node.estimated_days, estimated_days: node.estimated_days,
prerequisites, 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 rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop 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 y = event.clientY - rect.top + scrollTop - 40
const stageName = getStageAtY(Math.max(0, y))
const newNode: PathNode = { const newNode: PathNode = {
id: `n${course.id}`, id: `n${course.id}`,
course_id: course.id, course_id: course.id,
@@ -639,16 +692,23 @@ const handleDrop = (event: DragEvent) => {
estimated_days: Math.ceil((course.duration_hours || 2) / 2), estimated_days: Math.ceil((course.duration_hours || 2) / 2),
x: Math.max(0, x), x: Math.max(0, x),
y: Math.max(0, y), y: Math.max(0, y),
stage_name: stageName,
} }
pathNodes.value.push(newNode) pathNodes.value.push(newNode)
ElMessage.success('课程添加成功') ElMessage.success(`已添加到"${stageName}"`)
} }
const startNodeDrag = (event: MouseEvent, node: PathNode) => { const startNodeDrag = (event: MouseEvent, node: PathNode) => {
if (!canvasRef.value) return if (!canvasRef.value) return
// 检查是否点击的是连接点
const target = event.target as HTMLElement
if (target.closest('.connection-handle')) return
draggedNode.value = node draggedNode.value = node
selectedNode.value = node
const rect = canvasRef.value.getBoundingClientRect() const rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop const scrollTop = canvasRef.value.scrollTop
@@ -658,27 +718,150 @@ const startNodeDrag = (event: MouseEvent, node: PathNode) => {
y: event.clientY - rect.top + scrollTop - node.y y: event.clientY - rect.top + scrollTop - node.y
} }
const handleMouseMove = (e: MouseEvent) => { document.addEventListener('mousemove', handleNodeMouseMove)
if (!draggedNode.value || !canvasRef.value) return 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 rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop const scrollTop = canvasRef.value.scrollTop
draggedNode.value.x = Math.max(0, e.clientX - rect.left + scrollLeft - dragOffset.value.x) mousePos.value = {
draggedNode.value.y = Math.max(0, e.clientY - rect.top + scrollTop - dragOffset.value.y) 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) => { const handleCanvasMouseMove = (event: MouseEvent) => {
selectedNode.value = selectedNode.value?.id === node.id ? null : node 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<string>()
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) => { const handleNodeCommand = (command: string, node: PathNode) => {
@@ -686,13 +869,6 @@ const handleNodeCommand = (command: string, node: PathNode) => {
case 'required': case 'required':
node.is_required = !node.is_required node.is_required = !node.is_required
break 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': case 'delete':
deleteNode(node) deleteNode(node)
break break
@@ -710,36 +886,6 @@ const deleteNode = (node: PathNode) => {
.catch(() => {}) .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 = () => { const clearCanvas = () => {
ElMessageBox.confirm('确定要清空画布吗?', '清空确认', { type: 'warning' }) ElMessageBox.confirm('确定要清空画布吗?', '清空确认', { type: 'warning' })
.then(() => { .then(() => {
@@ -758,15 +904,26 @@ const autoLayout = () => {
const cols = 3 const cols = 3
const xSpacing = 220 const xSpacing = 220
const ySpacing = 150 const yOffset = 30
const startX = 50
const startY = 50
pathNodes.value.forEach((node, index) => { // 按阶段分组
const row = Math.floor(index / cols) const stages = editingPath.value?.stages || []
const col = index % cols stages.forEach((stage, stageIndex) => {
node.x = startX + col * xSpacing const stageNodes = pathNodes.value.filter(n => n.stage_name === stage.name)
node.y = startY + row * ySpacing 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('自动布局完成') ElMessage.success('自动布局完成')
@@ -834,7 +991,7 @@ onMounted(() => {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
padding: 12px 20px; padding: 12px 20px;
margin-bottom: 16px; margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
flex-shrink: 0; flex-shrink: 0;
@@ -858,7 +1015,7 @@ onMounted(() => {
} }
.course-library { .course-library {
width: 280px; width: 260px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -950,32 +1107,42 @@ onMounted(() => {
position: relative; position: relative;
width: 100%; width: 100%;
min-width: 900px; min-width: 900px;
min-height: 800px; min-height: 700px;
background-image: background-image:
linear-gradient(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, .08) 1px, transparent 1px); linear-gradient(90deg, rgba(102, 126, 234, .06) 1px, transparent 1px);
background-size: 20px 20px; background-size: 20px 20px;
} }
.stage-divider { .stage-zone {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
height: 1px; border-bottom: 1px dashed rgba(102, 126, 234, 0.2);
background: linear-gradient(90deg, transparent, #667eea 20%, #667eea 80%, transparent); transition: background 0.2s;
opacity: 0.3;
.stage-label { &.stage-highlight {
background: rgba(102, 126, 234, 0.05);
}
.stage-divider {
position: absolute; position: absolute;
left: 16px; top: 0;
top: -10px; left: 0;
background: #fafafa; right: 0;
padding: 2px 10px;
font-size: 12px; .stage-label {
color: #667eea; position: absolute;
font-weight: 500; left: 12px;
border-radius: 10px; top: 8px;
border: 1px solid rgba(102, 126, 234, 0.3); 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 { .path-node {
position: absolute; position: absolute;
width: 170px; width: 170px;
padding: 14px; padding: 12px 14px;
background: #fff; background: #fff;
border: 2px solid #e4e7ed; border: 2px solid #e4e7ed;
border-radius: 10px; border-radius: 10px;
@@ -1004,10 +1171,12 @@ onMounted(() => {
transition: all 0.2s; transition: all 0.2s;
user-select: none; user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
z-index: 10;
&:hover { &: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; border-color: #667eea;
z-index: 20;
} }
&.is-required { &.is-required {
@@ -1020,11 +1189,25 @@ onMounted(() => {
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); 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 { .node-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 10px; margin-bottom: 8px;
.node-title { .node-title {
font-size: 13px; font-size: 13px;
@@ -1044,11 +1227,54 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.node-duration { .node-stage {
font-size: 12px; font-size: 11px;
color: #909399; 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 { .connections {
@@ -1058,7 +1284,16 @@ onMounted(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none; 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) { @media (max-width: 1024px) {
@@ -1120,7 +1335,7 @@ onMounted(() => {
.course-library { .course-library {
width: 100%; width: 100%;
max-height: 250px; max-height: 200px;
} }
} }
} }