diff --git a/frontend/src/views/manager/growth-path-management.vue b/frontend/src/views/manager/growth-path-management.vue index cce4d59..e803c54 100644 --- a/frontend/src/views/manager/growth-path-management.vue +++ b/frontend/src/views/manager/growth-path-management.vue @@ -2,9 +2,9 @@
- - - + - - - - + - - -
- -
- - 创建第一个路径
- + + + + +
+

为课程 {{ currentNode?.title }} 设置前置课程:

+

选中的课程将作为当前课程的前置条件,用箭头连接显示

+ + + {{ node.title }} + + + +
+ +
@@ -439,7 +295,7 @@ import { ref, computed, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { - Plus, ArrowLeft, Check, Delete, Search, Rank + Plus, ArrowLeft, Check, Delete, Search, More, Grid, Reading, FolderOpened } from '@element-plus/icons-vue' import { getGrowthPathConfigs, @@ -449,43 +305,49 @@ import { deleteGrowthPath, getManagerCourses, type GrowthPathListItem, - type GrowthPathConfig, type CreateGrowthPathNode, type StageConfig, } from '@/api/manager' import request from '@/api/request' -// 岗位接口 +// 接口定义 interface Position { id: number name: string - code: string - status: string } -// 课程接口 interface Course { id: number name?: string title?: string - description?: string - category?: string duration_hours?: number - estimatedDuration?: number + category?: string +} + +interface PathNode { + id: string + course_id: number + title: string + is_required: boolean + estimated_days: number + x: number + y: number + stage_name?: string +} + +interface Connection { + from: string + to: string } -// 编辑用的路径结构 interface EditingPath { id?: number name: string description?: string - position_id?: number // 单选(兼容旧数据) - position_ids: number[] // 多选(新数据) + position_ids: number[] stages: StageConfig[] estimated_duration_days?: number is_active: boolean - sort_order?: number - nodes: CreateGrowthPathNode[] } // ========== 状态 ========== @@ -493,103 +355,57 @@ const loading = ref(false) const saving = ref(false) const coursesLoading = ref(false) -// 路径列表 +// 列表 const growthPaths = ref([]) -const total = ref(0) -const pagination = ref({ - page: 1, - page_size: 20, -}) -const filters = ref<{ - position_id?: number - is_active?: boolean -}>({}) +const filters = ref<{ position_id?: number }>({}) -// 编辑状态 +// 编辑 const editingPath = ref(null) const courseSearch = ref('') -const courseCategory = ref('') -const isDragging = ref(false) -const isDraggingOver = ref(false) +const canvasRef = ref() -// 节点拖拽排序状态 -const draggingNode = ref(null) -const draggingFromStage = ref('') -const dragOverNode = ref(null) -const dragOverPosition = ref<'top' | 'bottom'>('bottom') -const dragOverStage = ref('') +// 画布节点和连接 +const pathNodes = ref([]) +const connections = ref([]) +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 positions = ref([]) const courses = ref([]) // ========== 计算属性 ========== -// 获取所有课程分类 -const courseCategories = computed(() => { - const categories = new Set() - courses.value.forEach(c => { - if (c.category) categories.add(c.category) - }) - return Array.from(categories).sort() -}) - const filteredCourses = computed(() => { - let result = courses.value - - // 按分类筛选 - if (courseCategory.value) { - result = result.filter(c => c.category === courseCategory.value) - } - - // 按关键词搜索 - if (courseSearch.value) { - const keyword = courseSearch.value.toLowerCase() - result = result.filter(c => - (c.name || c.title || '').toLowerCase().includes(keyword) || - (c.category || '').toLowerCase().includes(keyword) - ) - } - - return result + if (!courseSearch.value) return courses.value + const keyword = courseSearch.value.toLowerCase() + return courses.value.filter(c => + (c.name || c.title || '').toLowerCase().includes(keyword) + ) }) -const requiredCount = computed(() => { - return editingPath.value?.nodes?.filter(n => n.is_required).length || 0 -}) +const requiredCount = computed(() => pathNodes.value.filter(n => n.is_required).length) const totalDuration = computed(() => { - if (!editingPath.value?.nodes) return 0 - return editingPath.value.nodes.reduce((sum, node) => { + return pathNodes.value.reduce((sum, node) => { const course = courses.value.find(c => c.id === node.course_id) - return sum + (course?.duration_hours || course?.estimatedDuration || 0) + return sum + (course?.duration_hours || 0) }, 0) }) -const isAllPositionsSelected = computed(() => { - if (!editingPath.value?.position_ids || positions.value.length === 0) return false - return editingPath.value.position_ids.length === positions.value.length -}) +const availableDependencies = computed(() => + pathNodes.value.filter(n => n.id !== currentNode.value?.id) +) // ========== 方法 ========== -/** - * 格式化日期时间 - */ -const formatDateTime = (dateStr: string) => { - if (!dateStr) return '-' - const date = new Date(dateStr) - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) -} - -/** - * 加载岗位列表 - */ const loadPositions = async () => { try { const res = await request.get<{ data: { items: Position[] } }>('/api/v1/admin/positions', { @@ -597,58 +413,43 @@ const loadPositions = async () => { }) positions.value = res.data?.items || [] } catch (error) { - console.error('加载岗位列表失败:', error) + console.error('加载岗位失败:', error) } } -/** - * 加载课程列表 - */ const loadCourses = async () => { coursesLoading.value = true try { - // 后端限制 size 最大 100 const res = await getManagerCourses({ page: 1, size: 100 }) - // API返回格式: { code, message, data: { items, total, page, size } } courses.value = res.data?.items || res.items || [] - console.log('加载课程列表成功:', courses.value.length, '门课程') } catch (error) { - console.error('加载课程列表失败:', error) + console.error('加载课程失败:', error) } finally { coursesLoading.value = false } } -/** - * 加载成长路径列表 - */ const loadGrowthPaths = async () => { loading.value = true try { const res = await getGrowthPathConfigs({ - page: pagination.value.page, - page_size: pagination.value.page_size, + page: 1, + page_size: 100, position_id: filters.value.position_id, - is_active: filters.value.is_active, }) growthPaths.value = res.items || [] - total.value = res.total || 0 } catch (error) { - console.error('加载成长路径列表失败:', error) - ElMessage.error('加载失败') + console.error('加载路径失败:', error) } finally { loading.value = false } } -/** - * 创建新路径 - */ +const isNodeAdded = (courseId: number) => pathNodes.value.some(n => n.course_id === courseId) + const handleCreatePath = () => { editingPath.value = { name: '', - description: '', - position_id: undefined, position_ids: [], stages: [ { name: '入门阶段', order: 1 }, @@ -657,31 +458,21 @@ const handleCreatePath = () => { ], estimated_duration_days: 30, is_active: true, - nodes: [], } + pathNodes.value = [] + connections.value = [] } -/** - * 编辑路径 - */ const handleEditPath = async (row: GrowthPathListItem) => { loading.value = true try { const detail = await getGrowthPathDetail(row.id) - // 兼容旧数据:如果有 position_id 但没有 position_ids,则转换 - let positionIds: number[] = [] - if (detail.position_ids && detail.position_ids.length > 0) { - positionIds = detail.position_ids - } else if (detail.position_id) { - positionIds = [detail.position_id] - } editingPath.value = { id: detail.id, name: detail.name, description: detail.description, - position_id: detail.position_id, - position_ids: positionIds, + position_ids: detail.position_ids || (detail.position_id ? [detail.position_id] : []), stages: detail.stages || [ { name: '入门阶段', order: 1 }, { name: '提升阶段', order: 2 }, @@ -689,45 +480,53 @@ const handleEditPath = async (row: GrowthPathListItem) => { ], estimated_duration_days: detail.estimated_duration_days, is_active: detail.is_active, - sort_order: detail.sort_order, - nodes: detail.nodes?.map(n => ({ - course_id: n.course_id, - stage_name: n.stage_name, - title: n.title, - description: n.description, - order_num: n.order_num, - is_required: n.is_required, - prerequisites: n.prerequisites, - estimated_days: n.estimated_days, - })) || [], } + + // 转换节点数据 + 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, + })) + + // 转换前置课程为连接线 + connections.value = [] + ;(detail.nodes || []).forEach(n => { + if (n.prerequisites && n.prerequisites.length > 0) { + n.prerequisites.forEach(preId => { + connections.value.push({ + from: `n${preId}`, + to: `n${n.course_id}` + }) + }) + } + }) } catch (error) { console.error('加载路径详情失败:', error) - ElMessage.error('加载路径详情失败') + ElMessage.error('加载失败') } finally { loading.value = false } } -/** - * 返回列表 - */ const handleBack = () => { ElMessageBox.confirm('确定要返回吗?未保存的更改将丢失。', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', type: 'warning', }).then(() => { editingPath.value = null + pathNodes.value = [] + connections.value = [] }).catch(() => {}) } -/** - * 保存路径 - */ const handleSavePath = async () => { if (!editingPath.value) return - if (!editingPath.value.name?.trim()) { ElMessage.warning('请输入路径名称') return @@ -735,23 +534,36 @@ const handleSavePath = async () => { saving.value = true try { - // 重新计算 order_num - const nodesWithOrder = editingPath.value.nodes.map((node, index) => ({ - ...node, - order_num: index + 1, - })) + // 转换节点数据 + const nodes: CreateGrowthPathNode[] = pathNodes.value.map((node, index) => { + // 获取前置课程 + const prerequisites = connections.value + .filter(c => c.to === node.id) + .map(c => { + const fromNode = pathNodes.value.find(n => n.id === c.from) + return fromNode?.course_id + }) + .filter(Boolean) as number[] + + return { + course_id: node.course_id, + title: node.title, + stage_name: node.stage_name || editingPath.value!.stages[0]?.name || '入门阶段', + order_num: index + 1, + is_required: node.is_required, + estimated_days: node.estimated_days, + prerequisites, + } + }) const payload = { name: editingPath.value.name, description: editingPath.value.description, position_ids: editingPath.value.position_ids, - // 兼容旧接口:取第一个岗位作为 position_id - position_id: editingPath.value.position_ids.length > 0 ? editingPath.value.position_ids[0] : undefined, stages: editingPath.value.stages, estimated_duration_days: editingPath.value.estimated_duration_days, is_active: editingPath.value.is_active, - sort_order: editingPath.value.sort_order || 0, - nodes: nodesWithOrder, + nodes, } if (editingPath.value.id) { @@ -763,457 +575,201 @@ const handleSavePath = async () => { } editingPath.value = null + pathNodes.value = [] + connections.value = [] loadGrowthPaths() } catch (error: any) { - console.error('保存失败:', error) ElMessage.error(error.message || '保存失败') } finally { saving.value = false } } -/** - * 切换启用状态 - */ const handleToggleStatus = async (row: GrowthPathListItem) => { try { await updateGrowthPath(row.id, { is_active: !row.is_active }) ElMessage.success(row.is_active ? '已禁用' : '已启用') loadGrowthPaths() } catch (error) { - console.error('切换状态失败:', error) ElMessage.error('操作失败') } } -/** - * 删除路径 - */ const handleDeletePath = async (row: GrowthPathListItem) => { try { - await ElMessageBox.confirm( - `确定要删除路径"${row.name}"吗?此操作不可恢复。`, - '删除确认', - { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'warning', - } - ) + await ElMessageBox.confirm(`确定要删除路径"${row.name}"吗?`, '删除确认', { type: 'warning' }) await deleteGrowthPath(row.id) ElMessage.success('删除成功') loadGrowthPaths() } catch (error: any) { - if (error !== 'cancel') { - console.error('删除失败:', error) - ElMessage.error('删除失败') - } + if (error !== 'cancel') ElMessage.error('删除失败') } } -/** - * 添加阶段 - */ -const addStage = () => { - if (!editingPath.value) return - const order = editingPath.value.stages.length + 1 - editingPath.value.stages.push({ - name: `阶段${order}`, - order, - }) -} +// ========== 画布操作 ========== -/** - * 移除阶段 - */ -const removeStage = (index: number) => { - if (!editingPath.value) return - const stageName = editingPath.value.stages[index].name - - // 检查是否有课程在此阶段 - const hasNodes = editingPath.value.nodes.some(n => n.stage_name === stageName) - if (hasNodes) { - ElMessage.warning('请先移除该阶段下的所有课程') - return - } - - editingPath.value.stages.splice(index, 1) -} - -/** - * 课程搜索 - */ -const handleCourseSearch = () => { - // 由计算属性自动处理 -} - -/** - * 检查课程是否已添加 - */ -const isNodeAdded = (courseId: number) => { - return editingPath.value?.nodes?.some(n => n.course_id === courseId) -} - -/** - * 获取某阶段的节点 - */ -const getStageNodes = (stageName: string) => { - return editingPath.value?.nodes?.filter(n => n.stage_name === stageName) || [] -} - -/** - * 全选/取消全选岗位 - */ -const handleSelectAllPositions = () => { - if (!editingPath.value) return - const allIds = positions.value.map(p => p.id) - // 如果当前已全选,则取消全选 - if (editingPath.value.position_ids?.length === allIds.length) { - editingPath.value.position_ids = [] - } else { - editingPath.value.position_ids = [...allIds] - } -} - -/** - * 添加课程 - */ -const handleAddCourse = (course: Course) => { - if (!editingPath.value) return - if (isNodeAdded(course.id)) { - ElMessage.warning('该课程已添加') - return - } - - const defaultStage = editingPath.value.stages[0]?.name || '入门阶段' - editingPath.value.nodes.push({ - course_id: course.id, - stage_name: defaultStage, - title: course.name || course.title || '', - description: course.description, - order_num: editingPath.value.nodes.length + 1, - is_required: true, - estimated_days: Math.ceil((course.duration_hours || course.estimatedDuration || 2) / 2), - }) - ElMessage.success('已添加') -} - -/** - * 拖拽开始 - */ const handleDragStart = (event: DragEvent, course: Course) => { - isDragging.value = true event.dataTransfer!.effectAllowed = 'copy' event.dataTransfer!.setData('course', JSON.stringify(course)) - // 设置拖拽图像 - const target = event.target as HTMLElement - if (target) { - target.classList.add('is-dragging') - } } -/** - * 拖拽进入目标区域 - */ -const handleDragOver = (event: DragEvent) => { - event.preventDefault() - isDraggingOver.value = true -} - -/** - * 拖拽离开目标区域 - */ -const handleDragLeave = (event: DragEvent) => { - // 检查是否真的离开了(避免子元素触发) - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() - const x = event.clientX - const y = event.clientY - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { - isDraggingOver.value = false - } -} - -/** - * 放置处理 - */ const handleDrop = (event: DragEvent) => { event.preventDefault() - isDragging.value = false - isDraggingOver.value = false - const courseData = event.dataTransfer!.getData('course') - if (!courseData) return + if (!courseData || !canvasRef.value) return const course = JSON.parse(courseData) as Course - handleAddCourse(course) -} - -/** - * 切换必修/选修 - */ -const toggleRequired = (node: CreateGrowthPathNode) => { - node.is_required = !node.is_required -} - -/** - * 移除节点 - */ -const removeNode = (node: CreateGrowthPathNode) => { - if (!editingPath.value) return - const index = editingPath.value.nodes.findIndex(n => n.course_id === node.course_id) - if (index > -1) { - editingPath.value.nodes.splice(index, 1) - ElMessage.success('已移除') - } -} - -/** - * 节点阶段变更时重新排序 - */ -const handleNodeStageChange = (node: CreateGrowthPathNode) => { - // 阶段变更后自动调整到该阶段的末尾 - reorderNodes() -} - -/** - * 重新排序节点(按阶段分组) - */ -const reorderNodes = () => { - if (!editingPath.value) return - // 按阶段顺序重新排列 - const stageOrder = editingPath.value.stages.map(s => s.name) - editingPath.value.nodes.sort((a, b) => { - const stageIndexA = stageOrder.indexOf(a.stage_name) - const stageIndexB = stageOrder.indexOf(b.stage_name) - if (stageIndexA !== stageIndexB) { - return stageIndexA - stageIndexB - } - return (a.order_num || 0) - (b.order_num || 0) - }) - // 更新 order_num - editingPath.value.nodes.forEach((node, index) => { - node.order_num = index + 1 - }) -} - -// ========== 节点拖拽排序 ========== - -/** - * 节点开始拖拽 - */ -const handleNodeDragStart = (event: DragEvent, node: CreateGrowthPathNode, stageName: string) => { - draggingNode.value = node - draggingFromStage.value = stageName - event.dataTransfer!.effectAllowed = 'move' - event.dataTransfer!.setData('node', JSON.stringify(node)) - // 添加拖拽样式 - const target = event.target as HTMLElement - setTimeout(() => { - target.classList.add('is-dragging') - }, 0) -} - -/** - * 节点拖拽结束 - */ -const handleNodeDragEnd = () => { - draggingNode.value = null - draggingFromStage.value = '' - dragOverNode.value = null - dragOverPosition.value = 'bottom' - dragOverStage.value = '' -} - -/** - * 节点拖拽经过另一个节点 - */ -const handleNodeDragOver = (event: DragEvent, targetNode: CreateGrowthPathNode) => { - if (!draggingNode.value || draggingNode.value.course_id === targetNode.course_id) return - event.preventDefault() - dragOverNode.value = targetNode - - // 判断是放在目标节点的上方还是下方 - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() - const midY = rect.top + rect.height / 2 - dragOverPosition.value = event.clientY < midY ? 'top' : 'bottom' -} - -/** - * 节点拖拽离开 - */ -const handleNodeDragLeave = () => { - dragOverNode.value = null -} - -/** - * 节点放置到另一个节点上 - */ -const handleNodeDropOnNode = (event: DragEvent, targetNode: CreateGrowthPathNode, targetStage: string) => { - event.preventDefault() - - if (!draggingNode.value || !editingPath.value) return - if (draggingNode.value.course_id === targetNode.course_id) return - - // 找到拖拽节点的索引 - const fromIndex = editingPath.value.nodes.findIndex(n => n.course_id === draggingNode.value!.course_id) - if (fromIndex === -1) return - - // 从原位置移除 - const [movedNode] = editingPath.value.nodes.splice(fromIndex, 1) - - // 更新阶段 - movedNode.stage_name = targetStage - - // 找到目标节点的新索引(因为可能已经移除了一个元素) - const toIndex = editingPath.value.nodes.findIndex(n => n.course_id === targetNode.course_id) - - // 根据放置位置插入 - if (dragOverPosition.value === 'top') { - editingPath.value.nodes.splice(toIndex, 0, movedNode) - } else { - editingPath.value.nodes.splice(toIndex + 1, 0, movedNode) - } - - // 重新计算 order_num - editingPath.value.nodes.forEach((node, index) => { - node.order_num = index + 1 - }) - - // 清理状态 - handleNodeDragEnd() - ElMessage.success('排序已更新') -} - -/** - * 阶段区域拖拽经过 - */ -const handleStageDragOver = (event: DragEvent, stageName: string) => { - event.preventDefault() - dragOverStage.value = stageName -} - -/** - * 阶段区域拖拽离开 - */ -const handleStageDragLeave = (event: DragEvent) => { - // 检查是否真的离开了阶段区域 - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() - const x = event.clientX - const y = event.clientY - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { - dragOverStage.value = '' - } -} - -/** - * 节点放置到阶段区域(空白处) - */ -const handleNodeDrop = (event: DragEvent, targetStage: string) => { - event.preventDefault() - - // 优先处理从课程库拖入的课程 - const courseData = event.dataTransfer!.getData('course') - if (courseData) { - const course = JSON.parse(courseData) as Course - handleAddCourseToStage(course, targetStage) - dragOverStage.value = '' - return - } - - // 处理节点排序 - if (!draggingNode.value || !editingPath.value) { - dragOverStage.value = '' - return - } - - // 移动节点到目标阶段末尾 - const fromIndex = editingPath.value.nodes.findIndex(n => n.course_id === draggingNode.value!.course_id) - if (fromIndex === -1) return - - const [movedNode] = editingPath.value.nodes.splice(fromIndex, 1) - movedNode.stage_name = targetStage - - // 找到该阶段最后一个节点的位置,插入其后 - const stageNodes = editingPath.value.nodes.filter(n => n.stage_name === targetStage) - if (stageNodes.length > 0) { - const lastNode = stageNodes[stageNodes.length - 1] - const lastIndex = editingPath.value.nodes.findIndex(n => n.course_id === lastNode.course_id) - editingPath.value.nodes.splice(lastIndex + 1, 0, movedNode) - } else { - // 该阶段为空,找到下一个阶段的第一个节点位置 - const stageOrder = editingPath.value.stages.map(s => s.name) - const targetStageIndex = stageOrder.indexOf(targetStage) - let insertIndex = editingPath.value.nodes.length - - for (let i = targetStageIndex + 1; i < stageOrder.length; i++) { - const nextStageFirstNode = editingPath.value.nodes.find(n => n.stage_name === stageOrder[i]) - if (nextStageFirstNode) { - insertIndex = editingPath.value.nodes.indexOf(nextStageFirstNode) - break - } - } - editingPath.value.nodes.splice(insertIndex, 0, movedNode) - } - - // 重新计算 order_num - editingPath.value.nodes.forEach((node, index) => { - node.order_num = index + 1 - }) - - handleNodeDragEnd() - ElMessage.success('已移动到' + targetStage) -} - -/** - * 添加课程到指定阶段 - */ -const handleAddCourseToStage = (course: Course, stageName: string) => { - if (!editingPath.value) return if (isNodeAdded(course.id)) { - ElMessage.warning('该课程已添加') + ElMessage.warning('该课程已在路径中') return } - // 找到该阶段最后一个节点的位置 - const stageNodes = editingPath.value.nodes.filter(n => n.stage_name === stageName) - let insertIndex = editingPath.value.nodes.length - - if (stageNodes.length > 0) { - const lastNode = stageNodes[stageNodes.length - 1] - insertIndex = editingPath.value.nodes.indexOf(lastNode) + 1 - } else { - // 该阶段为空,找到正确的插入位置 - const stageOrder = editingPath.value.stages.map(s => s.name) - const targetStageIndex = stageOrder.indexOf(stageName) - - for (let i = targetStageIndex + 1; i < stageOrder.length; i++) { - const nextStageFirstNode = editingPath.value.nodes.find(n => n.stage_name === stageOrder[i]) - if (nextStageFirstNode) { - insertIndex = editingPath.value.nodes.indexOf(nextStageFirstNode) - break - } - } - } + const rect = canvasRef.value.getBoundingClientRect() + const scrollLeft = canvasRef.value.scrollLeft + const scrollTop = canvasRef.value.scrollTop + const x = event.clientX - rect.left + scrollLeft - 75 + const y = event.clientY - rect.top + scrollTop - 40 - const newNode: CreateGrowthPathNode = { + const newNode: PathNode = { + id: `n${course.id}`, course_id: course.id, - stage_name: stageName, title: course.name || course.title || '', - description: course.description, - order_num: insertIndex + 1, is_required: true, - estimated_days: Math.ceil((course.duration_hours || course.estimatedDuration || 2) / 2), + estimated_days: Math.ceil((course.duration_hours || 2) / 2), + x: Math.max(0, x), + y: Math.max(0, y), } - editingPath.value.nodes.splice(insertIndex, 0, newNode) + pathNodes.value.push(newNode) + ElMessage.success('课程添加成功') +} + +const startNodeDrag = (event: MouseEvent, node: PathNode) => { + if (!canvasRef.value) return - // 重新计算 order_num - editingPath.value.nodes.forEach((node, index) => { - node.order_num = index + 1 + draggedNode.value = node + const rect = canvasRef.value.getBoundingClientRect() + const scrollLeft = canvasRef.value.scrollLeft + const scrollTop = canvasRef.value.scrollTop + + dragOffset.value = { + x: event.clientX - rect.left + scrollLeft - node.x, + y: event.clientY - rect.top + scrollTop - node.y + } + + const handleMouseMove = (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) + } + + 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 handleNodeCommand = (command: string, node: PathNode) => { + switch (command) { + 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 + } +} + +const deleteNode = (node: PathNode) => { + ElMessageBox.confirm(`确定要删除课程"${node.title}"吗?`, '删除确认', { type: 'warning' }) + .then(() => { + const index = pathNodes.value.findIndex(n => n.id === node.id) + if (index > -1) pathNodes.value.splice(index, 1) + connections.value = connections.value.filter(c => c.from !== node.id && c.to !== node.id) + ElMessage.success('删除成功') + }) + .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 }) }) - ElMessage.success(`已添加到${stageName}`) + 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(() => { + pathNodes.value = [] + connections.value = [] + ElMessage.success('画布已清空') + }) + .catch(() => {}) +} + +const autoLayout = () => { + if (pathNodes.value.length === 0) { + ElMessage.warning('画布为空') + return + } + + const cols = 3 + const xSpacing = 220 + const ySpacing = 150 + const startX = 50 + const startY = 50 + + 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 + }) + + ElMessage.success('自动布局完成') } // ========== 生命周期 ========== @@ -1226,17 +782,17 @@ onMounted(() => {