feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
955
frontend/src/views/manager/growth-path-management.vue
Normal file
955
frontend/src/views/manager/growth-path-management.vue
Normal file
@@ -0,0 +1,955 @@
|
||||
<template>
|
||||
<div class="growth-path-management-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">成长路径管理</h1>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="selectedPosition" placeholder="选择岗位" @change="handlePositionChange">
|
||||
<el-option label="全部岗位" value="" />
|
||||
<el-option label="销售专员" value="sales" />
|
||||
<el-option label="销售主管" value="sales_manager" />
|
||||
<el-option label="客服专员" value="service" />
|
||||
<el-option label="技术支持" value="tech" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="saveCurrentPath" :disabled="!hasChanges">
|
||||
<el-icon class="el-icon--left"><DocumentChecked /></el-icon>
|
||||
保存路径
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<el-alert
|
||||
title="操作提示"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
从左侧课程库拖拽课程到右侧画布,设计岗位的学习路径。可以设置课程之间的前置依赖关系。
|
||||
</el-alert>
|
||||
|
||||
<div class="path-designer">
|
||||
<!-- 左侧课程库 -->
|
||||
<div class="course-library card">
|
||||
<div class="library-header">
|
||||
<h3>课程库</h3>
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索课程"
|
||||
clearable
|
||||
size="small"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="library-content">
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item title="销售技巧" name="sales">
|
||||
<div class="course-list">
|
||||
<div
|
||||
v-for="course in filteredSalesCourses"
|
||||
:key="course.id"
|
||||
class="course-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, course)"
|
||||
>
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>{{ course.title }}</span>
|
||||
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="产品知识" name="product">
|
||||
<div class="course-list">
|
||||
<div
|
||||
v-for="course in filteredProductCourses"
|
||||
:key="course.id"
|
||||
class="course-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, course)"
|
||||
>
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>{{ course.title }}</span>
|
||||
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="客户服务" name="service">
|
||||
<div class="course-list">
|
||||
<div
|
||||
v-for="course in filteredServiceCourses"
|
||||
:key="course.id"
|
||||
class="course-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, course)"
|
||||
>
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>{{ course.title }}</span>
|
||||
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="通用能力" name="general">
|
||||
<div class="course-list">
|
||||
<div
|
||||
v-for="course in filteredGeneralCourses"
|
||||
:key="course.id"
|
||||
class="course-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, course)"
|
||||
>
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>{{ course.title }}</span>
|
||||
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧画布 -->
|
||||
<div class="path-canvas card">
|
||||
<div class="canvas-header">
|
||||
<h3>{{ selectedPosition ? getPositionName(selectedPosition) : '请选择岗位' }} - 成长路径</h3>
|
||||
<div class="canvas-actions">
|
||||
<el-button link type="primary" size="small" @click="clearCanvas">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空画布
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="autoLayout">
|
||||
<el-icon><Grid /></el-icon>
|
||||
自动布局
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="canvas-content"
|
||||
@dragover.prevent
|
||||
@drop="handleDrop"
|
||||
ref="canvasRef"
|
||||
>
|
||||
<div class="canvas-inner">
|
||||
<!-- 阶段分隔线 -->
|
||||
<div class="stage-divider" v-for="stage in stages" :key="stage.id" :style="{ top: stage.position + 'px' }">
|
||||
<span class="stage-label">{{ stage.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="pathNodes.length === 0" class="empty-state">
|
||||
<el-icon :size="64" color="#c0c4cc"><FolderOpened /></el-icon>
|
||||
<p>拖拽课程到这里开始设计成长路径</p>
|
||||
</div>
|
||||
|
||||
<!-- 路径节点 -->
|
||||
<div
|
||||
v-for="node in pathNodes"
|
||||
:key="node.id"
|
||||
class="path-node"
|
||||
:class="{ 'is-required': node.required }"
|
||||
:style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
@mousedown="startDrag($event, node)"
|
||||
@contextmenu.prevent="showNodeMenu($event, node)"
|
||||
>
|
||||
<div class="node-header">
|
||||
<span class="node-title">{{ node.title }}</span>
|
||||
<el-dropdown trigger="click" @command="handleNodeCommand($event, node)">
|
||||
<el-button link size="small">
|
||||
<el-icon><More /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="required">
|
||||
{{ node.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>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<el-tag size="small" :type="node.required ? 'danger' : ''">
|
||||
{{ node.required ? '必修' : '选修' }}
|
||||
</el-tag>
|
||||
<span class="node-duration">{{ node.duration }}分钟</span>
|
||||
</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 class="canvas-footer">
|
||||
<div class="path-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">课程总数:</span>
|
||||
<span class="stat-value">{{ pathNodes.length }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">必修课程:</span>
|
||||
<span class="stat-value">{{ requiredCount }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总时长:</span>
|
||||
<span class="stat-value">{{ totalDuration }} 分钟</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">预计完成天数:</span>
|
||||
<span class="stat-value">{{ estimatedDays }} 天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置前置课程弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dependencyDialogVisible"
|
||||
title="设置前置课程"
|
||||
width="500px"
|
||||
>
|
||||
<div class="dependency-content">
|
||||
<p>为课程 <strong>{{ currentNode?.title }}</strong> 设置前置课程:</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>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dependencyDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveDependencies">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// 选中的岗位
|
||||
const selectedPosition = ref('sales')
|
||||
const hasChanges = ref(false)
|
||||
|
||||
// 搜索文本
|
||||
const searchText = ref('')
|
||||
const activeNames = ref(['sales'])
|
||||
|
||||
// 画布相关
|
||||
const canvasRef = ref()
|
||||
const pathNodes = ref<any[]>([])
|
||||
const connections = ref<any[]>([])
|
||||
const draggedNode = ref<any>(null)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// 前置课程设置
|
||||
const dependencyDialogVisible = ref(false)
|
||||
const currentNode = ref<any>(null)
|
||||
const selectedDependencies = ref<string[]>([])
|
||||
|
||||
// 阶段分隔线
|
||||
const stages = ref([
|
||||
{ id: 1, name: '入门阶段', position: 100 },
|
||||
{ id: 2, name: '提升阶段', position: 300 },
|
||||
{ id: 3, name: '进阶阶段', position: 500 },
|
||||
{ id: 4, name: '专家阶段', position: 700 }
|
||||
])
|
||||
|
||||
// 课程数据
|
||||
const salesCourses = ref([
|
||||
{ id: 'c1', title: '客户沟通技巧', duration: 120 },
|
||||
{ id: 'c2', title: '需求挖掘方法', duration: 90 },
|
||||
{ id: 'c3', title: '异议处理技巧', duration: 60 },
|
||||
{ id: 'c4', title: '成交技巧', duration: 90 }
|
||||
])
|
||||
|
||||
const productCourses = ref([
|
||||
{ id: 'c5', title: '产品基础知识', duration: 180 },
|
||||
{ id: 'c6', title: '产品对比分析', duration: 120 },
|
||||
{ id: 'c7', title: '产品演示技巧', duration: 90 }
|
||||
])
|
||||
|
||||
const serviceCourses = ref([
|
||||
{ id: 'c8', title: '客户服务礼仪', duration: 60 },
|
||||
{ id: 'c9', title: '投诉处理技巧', duration: 90 },
|
||||
{ id: 'c10', title: '客户关系维护', duration: 120 }
|
||||
])
|
||||
|
||||
// 过滤后的课程数据
|
||||
const filteredSalesCourses = computed(() => {
|
||||
if (!searchText.value) return salesCourses.value
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
return salesCourses.value.filter(course =>
|
||||
course.title.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredProductCourses = computed(() => {
|
||||
if (!searchText.value) return productCourses.value
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
return productCourses.value.filter(course =>
|
||||
course.title.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredServiceCourses = computed(() => {
|
||||
if (!searchText.value) return serviceCourses.value
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
return serviceCourses.value.filter(course =>
|
||||
course.title.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
const generalCourses = ref([
|
||||
{ id: 'c11', title: '时间管理', duration: 60 },
|
||||
{ id: 'c12', title: '商务礼仪', duration: 45 },
|
||||
{ id: 'c13', title: '团队协作', duration: 90 }
|
||||
])
|
||||
|
||||
const filteredGeneralCourses = computed(() => {
|
||||
if (!searchText.value) return generalCourses.value
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
return generalCourses.value.filter(course =>
|
||||
course.title.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const requiredCount = computed(() => pathNodes.value.filter(n => n.required).length)
|
||||
const totalDuration = computed(() => pathNodes.value.reduce((sum, n) => sum + n.duration, 0))
|
||||
const estimatedDays = computed(() => Math.ceil(totalDuration.value / 180)) // 假设每天学习3小时
|
||||
const availableDependencies = computed(() =>
|
||||
pathNodes.value.filter(n => n.id !== currentNode.value?.id)
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取岗位名称
|
||||
*/
|
||||
const getPositionName = (position: string) => {
|
||||
const positionMap: Record<string, string> = {
|
||||
sales: '销售专员',
|
||||
sales_manager: '销售主管',
|
||||
service: '客服专员',
|
||||
tech: '技术支持'
|
||||
}
|
||||
return positionMap[position] || position
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位切换
|
||||
*/
|
||||
const handlePositionChange = () => {
|
||||
if (hasChanges.value) {
|
||||
ElMessageBox.confirm(
|
||||
'当前路径尚未保存,切换岗位将丢失未保存的更改,是否继续?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
loadPathData()
|
||||
hasChanges.value = false
|
||||
}).catch(() => {
|
||||
// 恢复原选择
|
||||
})
|
||||
} else {
|
||||
loadPathData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载路径数据
|
||||
*/
|
||||
const loadPathData = () => {
|
||||
// 模拟加载不同岗位的路径数据
|
||||
if (selectedPosition.value === 'sales') {
|
||||
pathNodes.value = [
|
||||
{ id: 'n1', courseId: 'c5', title: '产品基础知识', duration: 180, required: true, x: 100, y: 50 },
|
||||
{ id: 'n2', courseId: 'c1', title: '客户沟通技巧', duration: 120, required: true, x: 350, y: 50 },
|
||||
{ id: 'n3', courseId: 'c2', title: '需求挖掘方法', duration: 90, required: true, x: 100, y: 250 },
|
||||
{ id: 'n4', courseId: 'c3', title: '异议处理技巧', duration: 60, required: false, x: 350, y: 250 }
|
||||
]
|
||||
connections.value = [
|
||||
{ from: 'n1', to: 'n2' },
|
||||
{ from: 'n2', to: 'n3' },
|
||||
{ from: 'n2', to: 'n4' }
|
||||
]
|
||||
} else {
|
||||
pathNodes.value = []
|
||||
connections.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始拖拽课程
|
||||
*/
|
||||
const handleDragStart = (event: DragEvent, course: any) => {
|
||||
event.dataTransfer!.effectAllowed = 'copy'
|
||||
event.dataTransfer!.setData('course', JSON.stringify(course))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理放置
|
||||
*/
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
const courseData = event.dataTransfer!.getData('course')
|
||||
if (!courseData) return
|
||||
|
||||
const course = JSON.parse(courseData)
|
||||
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 // 节点高度的一半
|
||||
|
||||
// 检查是否已存在
|
||||
if (pathNodes.value.some(n => n.courseId === course.id)) {
|
||||
ElMessage.warning('该课程已在路径中')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加新节点
|
||||
const newNode = {
|
||||
id: `n${Date.now()}`,
|
||||
courseId: course.id,
|
||||
title: course.title,
|
||||
duration: course.duration,
|
||||
required: true,
|
||||
x: Math.max(0, x), // 不限制最大值,让用户可以在更大的画布上放置
|
||||
y: Math.max(0, y)
|
||||
}
|
||||
|
||||
pathNodes.value.push(newNode)
|
||||
hasChanges.value = true
|
||||
ElMessage.success('课程添加成功')
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始拖拽节点
|
||||
*/
|
||||
const startDrag = (event: MouseEvent, node: any) => {
|
||||
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) 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)
|
||||
hasChanges.value = true
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
draggedNode.value = null
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示节点菜单
|
||||
*/
|
||||
const showNodeMenu = (_event: MouseEvent, _node: any) => {
|
||||
// 右键菜单逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理节点命令
|
||||
*/
|
||||
const handleNodeCommand = (command: string, node: any) => {
|
||||
switch (command) {
|
||||
case 'required':
|
||||
node.required = !node.required
|
||||
hasChanges.value = true
|
||||
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: any) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除课程"${node.title}"吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
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)
|
||||
hasChanges.value = true
|
||||
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 })
|
||||
})
|
||||
|
||||
hasChanges.value = true
|
||||
dependencyDialogVisible.value = false
|
||||
ElMessage.success('前置课程设置成功')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接路径
|
||||
*/
|
||||
const getConnectionPath = (conn: any) => {
|
||||
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 + 75 // 节点中心
|
||||
const y1 = fromNode.y + 80 // 节点底部
|
||||
const x2 = toNode.x + 75 // 节点中心
|
||||
const y2 = toNode.y // 节点顶部
|
||||
|
||||
// 贝塞尔曲线
|
||||
const cx = (x1 + x2) / 2
|
||||
const cy = (y1 + y2) / 2
|
||||
|
||||
return `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空画布
|
||||
*/
|
||||
const clearCanvas = () => {
|
||||
ElMessageBox.confirm(
|
||||
'确定要清空画布吗?此操作不可恢复。',
|
||||
'清空确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
pathNodes.value = []
|
||||
connections.value = []
|
||||
hasChanges.value = true
|
||||
ElMessage.success('画布已清空')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动布局
|
||||
*/
|
||||
const autoLayout = () => {
|
||||
if (pathNodes.value.length === 0) {
|
||||
ElMessage.warning('画布为空,无需布局')
|
||||
return
|
||||
}
|
||||
|
||||
// 简单的网格布局
|
||||
const cols = 3
|
||||
const xSpacing = 200
|
||||
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
|
||||
})
|
||||
|
||||
hasChanges.value = true
|
||||
ElMessage.success('自动布局完成')
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前路径
|
||||
*/
|
||||
const saveCurrentPath = () => {
|
||||
if (!selectedPosition.value) {
|
||||
ElMessage.warning('请先选择岗位')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(
|
||||
'确定要保存当前的成长路径吗?',
|
||||
'保存确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}
|
||||
).then(() => {
|
||||
// 模拟保存
|
||||
setTimeout(() => {
|
||||
hasChanges.value = false
|
||||
ElMessage.success('成长路径保存成功')
|
||||
}, 1000)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadPathData()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.growth-path-management-container {
|
||||
height: calc(100vh - 60px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.el-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.path-designer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.course-library {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.library-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.library-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
|
||||
.course-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.course-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
cursor: grab;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e6e8eb;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
> span {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.path-canvas {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.canvas-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.canvas-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #fafafa;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
|
||||
.canvas-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
min-height: 900px;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, .05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, .05) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.stage-divider {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #dcdfe6;
|
||||
|
||||
.stage-label {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: -10px;
|
||||
background: #fafafa;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.path-node {
|
||||
position: absolute;
|
||||
width: 150px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 2px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
cursor: move;
|
||||
transition: box-shadow 0.3s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.is-required {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.node-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.node-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.node-duration {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.connections {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
|
||||
.path-stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dependency-content {
|
||||
p {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.el-checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 1024px) {
|
||||
.growth-path-management-container {
|
||||
.path-designer {
|
||||
flex-direction: column;
|
||||
|
||||
.course-library {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user