新增功能: 1. 阶段自定义管理 - 添加/删除/编辑阶段名称 2. 列表分页功能 3. 状态筛选(启用/禁用) 4. 课程分类筛选 5. 岗位全选按钮 6. 创建时间列显示 7. 点击必修/选修标签直接切换 画布高度根据阶段数量动态调整 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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="关联岗位">
|
||||
<el-select
|
||||
v-model="editingPath.position_ids"
|
||||
placeholder="选择岗位"
|
||||
multiple
|
||||
collapse-tags
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option v-for="pos in positions" :key="pos.id" :label="pos.name" :value="pos.id" />
|
||||
</el-select>
|
||||
<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
|
||||
const keyword = courseSearch.value.toLowerCase()
|
||||
return courses.value.filter(c =>
|
||||
(c.name || c.title || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
let result = courses.value
|
||||
if (courseSearch.value) {
|
||||
const keyword = courseSearch.value.toLowerCase()
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user