feat: 完善成长路径管理功能
All checks were successful
continuous-integration/drone/push Build is passing

新增功能:
1. 阶段自定义管理 - 添加/删除/编辑阶段名称
2. 列表分页功能
3. 状态筛选(启用/禁用)
4. 课程分类筛选
5. 岗位全选按钮
6. 创建时间列显示
7. 点击必修/选修标签直接切换

画布高度根据阶段数量动态调整

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuliang_guo
2026-02-03 15:05:57 +08:00
parent 8892511f10
commit 344d8c1770

View File

@@ -10,7 +10,7 @@
placeholder="筛选岗位"
clearable
style="width: 180px"
@change="loadGrowthPaths"
@change="handleFilterChange"
>
<el-option label="全部岗位" :value="undefined" />
<el-option
@@ -20,6 +20,17 @@
:value="pos.id"
/>
</el-select>
<el-select
v-model="filters.is_active"
placeholder="状态"
clearable
style="width: 120px"
@change="handleFilterChange"
>
<el-option label="全部状态" :value="undefined" />
<el-option label="已启用" :value="true" />
<el-option label="已禁用" :value="false" />
</el-select>
<el-button type="primary" @click="handleCreatePath">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建路径
@@ -56,6 +67,9 @@
<el-table-column prop="estimated_duration_days" label="预计天数" width="100" align="center">
<template #default="{ row }">{{ row.estimated_duration_days || '-' }} </template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEditPath(row)">编辑</el-button>
@@ -66,6 +80,20 @@
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="loadGrowthPaths"
@current-change="loadGrowthPaths"
/>
</div>
<el-empty v-if="!loading && growthPaths.length === 0" description="暂无成长路径">
<el-button type="primary" @click="handleCreatePath">创建第一个路径</el-button>
</el-empty>
@@ -98,15 +126,21 @@
<el-input v-model="editingPath.name" placeholder="请输入路径名称" style="width: 200px" />
</el-form-item>
<el-form-item label="关联岗位">
<div class="position-select-wrapper">
<el-select
v-model="editingPath.position_ids"
placeholder="选择岗位"
multiple
collapse-tags
collapse-tags-tooltip
style="width: 200px"
>
<el-option v-for="pos in positions" :key="pos.id" :label="pos.name" :value="pos.id" />
</el-select>
<el-button link type="primary" size="small" @click="handleSelectAllPositions" class="select-all-btn">
{{ isAllPositionsSelected ? '取消全选' : '全选' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="预计天数">
<el-input-number v-model="editingPath.estimated_duration_days" :min="1" :max="365" />
@@ -117,10 +151,40 @@
</el-form>
</div>
<!-- 阶段管理栏 -->
<div class="stages-bar">
<div class="stages-header">
<span class="stages-title">学习阶段</span>
<el-button link type="primary" size="small" @click="addStage">
<el-icon><Plus /></el-icon> 添加阶段
</el-button>
</div>
<div class="stages-list">
<div v-for="(stage, index) in editingPath.stages" :key="index" class="stage-tag">
<span class="stage-order">{{ index + 1 }}</span>
<el-input
v-model="stage.name"
placeholder="阶段名称"
size="small"
style="width: 100px"
/>
<el-button
link
type="danger"
size="small"
@click="removeStage(index)"
:disabled="editingPath.stages.length <= 1"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 操作提示 -->
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
<template #title>
<span>操作提示从左侧拖拽课程到画布 | 拖拽节点右侧圆点可连接到其他课程箭头表示前置依赖 | 节点位置决定所属阶段</span>
<span>操作提示从左侧拖拽课程到画布 | 拖拽节点右侧圆点可连接到其他课程箭头表示前置依赖 | 节点位置决定所属阶段 | 点击必修/选修标签可快速切换</span>
</template>
</el-alert>
@@ -139,6 +203,20 @@
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="courseCategory"
placeholder="分类"
clearable
size="small"
style="width: 100px; margin-top: 8px"
>
<el-option
v-for="cat in courseCategories"
:key="cat"
:label="cat"
:value="cat"
/>
</el-select>
</div>
<div class="library-content" v-loading="coursesLoading">
@@ -183,11 +261,11 @@
@mouseup="handleCanvasMouseUp"
ref="canvasRef"
>
<div class="canvas-inner" ref="canvasInnerRef">
<div class="canvas-inner" ref="canvasInnerRef" :style="{ minHeight: canvasMinHeight + 'px' }">
<!-- 阶段分隔线和区域 -->
<div
v-for="(stage, index) in editingPath.stages"
:key="stage.name"
:key="stage.name + index"
class="stage-zone"
:style="{ top: (index * stageHeight) + 'px', height: stageHeight + 'px' }"
:class="{ 'stage-highlight': getStageAtY(draggedNode?.y || 0) === stage.name }"
@@ -267,7 +345,12 @@
</el-dropdown>
</div>
<div class="node-info">
<el-tag size="small" :type="node.is_required ? 'danger' : ''">
<el-tag
size="small"
:type="node.is_required ? 'danger' : ''"
style="cursor: pointer"
@click.stop="toggleNodeRequired(node)"
>
{{ node.is_required ? '必修' : '选修' }}
</el-tag>
<span class="node-stage">{{ node.stage_name }}</span>
@@ -320,7 +403,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, ArrowLeft, Check, Delete, Search, More, Grid, Reading, FolderOpened
@@ -349,6 +432,7 @@ interface Course {
name?: string
title?: string
duration_hours?: number
category?: string
}
interface PathNode {
@@ -387,11 +471,17 @@ const coursesLoading = ref(false)
// 列表
const growthPaths = ref<GrowthPathListItem[]>([])
const filters = ref<{ position_id?: number }>({})
const total = ref(0)
const pagination = ref({
page: 1,
page_size: 20
})
const filters = ref<{ position_id?: number; is_active?: boolean }>({})
// 编辑
const editingPath = ref<EditingPath | null>(null)
const courseSearch = ref('')
const courseCategory = ref('')
const canvasRef = ref<HTMLElement>()
const canvasInnerRef = ref<HTMLElement>()
@@ -415,12 +505,29 @@ const positions = ref<Position[]>([])
const courses = ref<Course[]>([])
// ========== 计算属性 ==========
// 课程分类列表
const courseCategories = computed(() => {
const cats = new Set<string>()
courses.value.forEach(c => {
if (c.category) cats.add(c.category)
})
return Array.from(cats)
})
// 过滤后的课程
const filteredCourses = computed(() => {
if (!courseSearch.value) return courses.value
let result = courses.value
if (courseSearch.value) {
const keyword = courseSearch.value.toLowerCase()
return courses.value.filter(c =>
result = result.filter(c =>
(c.name || c.title || '').toLowerCase().includes(keyword)
)
}
if (courseCategory.value) {
result = result.filter(c => c.category === courseCategory.value)
}
return result
})
const requiredCount = computed(() => pathNodes.value.filter(n => n.is_required).length)
@@ -432,8 +539,33 @@ const totalDuration = computed(() => {
}, 0)
})
// 是否全选岗位
const isAllPositionsSelected = computed(() => {
if (!editingPath.value || positions.value.length === 0) return false
return editingPath.value.position_ids.length === positions.value.length
})
// 画布最小高度(根据阶段数量动态计算)
const canvasMinHeight = computed(() => {
if (!editingPath.value) return 700
return Math.max(700, editingPath.value.stages.length * stageHeight + 100)
})
// ========== 工具方法 ==========
// 格式化日期时间
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
/**
* 根据Y坐标获取所属阶段
*/
@@ -451,6 +583,69 @@ const updateNodeStage = (node: PathNode) => {
node.stage_name = getStageAtY(node.y)
}
// ========== 阶段管理 ==========
const addStage = () => {
if (!editingPath.value) return
const newOrder = editingPath.value.stages.length + 1
editingPath.value.stages.push({
name: `阶段${newOrder}`,
order: newOrder
})
ElMessage.success('已添加新阶段')
}
const removeStage = (index: number) => {
if (!editingPath.value || editingPath.value.stages.length <= 1) return
const stageName = editingPath.value.stages[index].name
// 检查是否有节点在该阶段
const nodesInStage = pathNodes.value.filter(n => n.stage_name === stageName)
if (nodesInStage.length > 0) {
ElMessageBox.confirm(
`该阶段下有 ${nodesInStage.length} 个课程,删除后这些课程将移动到第一个阶段。确定删除吗?`,
'删除确认',
{ type: 'warning' }
).then(() => {
doRemoveStage(index, stageName)
}).catch(() => {})
} else {
doRemoveStage(index, stageName)
}
}
const doRemoveStage = (index: number, stageName: string) => {
if (!editingPath.value) return
// 移动该阶段的节点到第一个阶段
const firstStageName = editingPath.value.stages[0]?.name
pathNodes.value.forEach(node => {
if (node.stage_name === stageName && firstStageName) {
node.stage_name = firstStageName
// 重新计算位置
node.y = 30
}
})
editingPath.value.stages.splice(index, 1)
// 重新编号
editingPath.value.stages.forEach((s, i) => s.order = i + 1)
ElMessage.success('阶段已删除')
}
// ========== 岗位全选 ==========
const handleSelectAllPositions = () => {
if (!editingPath.value) return
if (isAllPositionsSelected.value) {
editingPath.value.position_ids = []
} else {
editingPath.value.position_ids = positions.value.map(p => p.id)
}
}
// ========== 数据加载 ==========
const loadPositions = async () => {
@@ -467,7 +662,7 @@ const loadPositions = async () => {
const loadCourses = async () => {
coursesLoading.value = true
try {
const res = await getManagerCourses({ page: 1, size: 100 })
const res = await getManagerCourses({ page: 1, size: 500 })
courses.value = res.data?.items || res.items || []
} catch (error) {
console.error('加载课程失败:', error)
@@ -476,15 +671,22 @@ const loadCourses = async () => {
}
}
const handleFilterChange = () => {
pagination.value.page = 1
loadGrowthPaths()
}
const loadGrowthPaths = async () => {
loading.value = true
try {
const res = await getGrowthPathConfigs({
page: 1,
page_size: 100,
page: pagination.value.page,
page_size: pagination.value.page_size,
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)
} finally {
@@ -864,6 +1066,11 @@ const getConnectionPath = (conn: Connection) => {
return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`
}
// 点击标签切换必修/选修
const toggleNodeRequired = (node: PathNode) => {
node.is_required = !node.is_required
}
const handleNodeCommand = (command: string, node: PathNode) => {
switch (command) {
case 'required':
@@ -977,6 +1184,8 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
flex: 1;
display: flex;
flex-direction: column;
.path-name-cell {
display: flex;
@@ -985,6 +1194,12 @@ onMounted(() => {
.path-name { font-weight: 500; }
}
.text-muted { color: #909399; }
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
}
.path-info-bar {
@@ -999,6 +1214,67 @@ onMounted(() => {
margin-bottom: 0;
margin-right: 24px;
}
.position-select-wrapper {
display: flex;
align-items: center;
gap: 8px;
.select-all-btn {
white-space: nowrap;
}
}
}
.stages-bar {
background: #fff;
border-radius: 8px;
padding: 12px 20px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
.stages-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.stages-title {
font-size: 14px;
font-weight: 600;
color: #333;
}
}
.stages-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
.stage-tag {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #f5f7fa;
border-radius: 6px;
border: 1px solid #e4e7ed;
.stage-order {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
}
}
}
}
.path-designer {
@@ -1107,7 +1383,6 @@ onMounted(() => {
position: relative;
width: 100%;
min-width: 900px;
min-height: 700px;
background-image:
linear-gradient(rgba(102, 126, 234, .06) 1px, transparent 1px),
linear-gradient(90deg, rgba(102, 126, 234, .06) 1px, transparent 1px);