feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

View 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>