后端: - 添加 position_x, position_y 字段保存节点位置 前端: - 支持从节点右侧圆点拖拽出箭头连接到其他课程 - 自动根据节点Y坐标识别所属阶段 - 保存并恢复节点位置,不再重置 - 阶段区域高亮显示 - 循环依赖检测 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -86,6 +86,14 @@ class GrowthPathNode(BaseModel, SoftDeleteMixin):
|
|||||||
estimated_days: Mapped[int] = mapped_column(
|
estimated_days: Mapped[int] = mapped_column(
|
||||||
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
|
||||||
|
|||||||
@@ -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坐标")
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@@ -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坐标
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建成长路径请求
|
// 创建成长路径请求
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user