后端: - 添加 position_x, position_y 字段保存节点位置 前端: - 支持从节点右侧圆点拖拽出箭头连接到其他课程 - 自动根据节点Y坐标识别所属阶段 - 保存并恢复节点位置,不再重置 - 阶段区域高亮显示 - 循环依赖检测 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -167,6 +167,8 @@ export interface CreateGrowthPathNode {
|
||||
is_required: boolean
|
||||
prerequisites?: number[]
|
||||
estimated_days: number
|
||||
position_x?: number // 画布X坐标
|
||||
position_y?: number // 画布Y坐标
|
||||
}
|
||||
|
||||
// 创建成长路径请求
|
||||
|
||||
@@ -117,6 +117,13 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作提示 -->
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
|
||||
<template #title>
|
||||
<span>操作提示:从左侧拖拽课程到画布 | 拖拽节点右侧圆点可连接到其他课程(箭头表示前置依赖) | 节点位置决定所属阶段</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="path-designer">
|
||||
<!-- 左侧课程库 -->
|
||||
<div class="course-library card">
|
||||
@@ -172,17 +179,22 @@
|
||||
class="canvas-content"
|
||||
@dragover.prevent
|
||||
@drop="handleDrop"
|
||||
@mousemove="handleCanvasMouseMove"
|
||||
@mouseup="handleCanvasMouseUp"
|
||||
ref="canvasRef"
|
||||
>
|
||||
<div class="canvas-inner">
|
||||
<!-- 阶段分隔线 -->
|
||||
<div class="canvas-inner" ref="canvasInnerRef">
|
||||
<!-- 阶段分隔线和区域 -->
|
||||
<div
|
||||
class="stage-divider"
|
||||
v-for="(stage, index) in editingPath.stages"
|
||||
:key="stage.name"
|
||||
:style="{ top: (index * 200 + 100) + 'px' }"
|
||||
:key="stage.name"
|
||||
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>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -191,15 +203,52 @@
|
||||
<p>拖拽课程到这里开始设计成长路径</p>
|
||||
</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
|
||||
v-for="node in pathNodes"
|
||||
:key="node.id"
|
||||
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' }"
|
||||
@mousedown="startNodeDrag($event, node)"
|
||||
@click.stop="selectNode(node)"
|
||||
@mousedown.stop="startNodeDrag($event, node)"
|
||||
>
|
||||
<div class="node-header">
|
||||
<span class="node-title">{{ node.title }}</span>
|
||||
@@ -212,7 +261,6 @@
|
||||
<el-dropdown-item command="required">
|
||||
{{ node.is_required ? '设为选修' : '设为必修' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="dependency">设置前置课程</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>删除节点</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -222,27 +270,25 @@
|
||||
<el-tag size="small" :type="node.is_required ? 'danger' : ''">
|
||||
{{ node.is_required ? '必修' : '选修' }}
|
||||
</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>
|
||||
|
||||
<!-- 连接线(箭头) -->
|
||||
<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>
|
||||
|
||||
@@ -261,33 +307,15 @@
|
||||
<span class="stat-label">总学时:</span>
|
||||
<span class="stat-value">{{ totalDuration }}h</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">连接数:</span>
|
||||
<span class="stat-value">{{ connections.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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<EditingPath | null>(null)
|
||||
const courseSearch = ref('')
|
||||
const canvasRef = ref<HTMLElement>()
|
||||
const canvasInnerRef = ref<HTMLElement>()
|
||||
|
||||
// 画布节点和连接
|
||||
const pathNodes = ref<PathNode[]>([])
|
||||
@@ -373,10 +404,11 @@ const selectedNode = ref<PathNode | null>(null)
|
||||
const draggedNode = ref<PathNode | null>(null)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// 前置课程
|
||||
const dependencyDialogVisible = ref(false)
|
||||
const currentNode = ref<PathNode | null>(null)
|
||||
const selectedDependencies = ref<string[]>([])
|
||||
// 连线绘制
|
||||
const isDrawingConnection = ref(false)
|
||||
const connectionStart = ref<PathNode | null>(null)
|
||||
const drawingLine = ref('')
|
||||
const mousePos = ref({ x: 0, y: 0 })
|
||||
|
||||
// 基础数据
|
||||
const positions = ref<Position[]>([])
|
||||
@@ -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<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) => {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user