feat: 成长路径支持多岗位关联 + 增强拖拽功能
All checks were successful
continuous-integration/drone/push Build is passing

前端:
- 岗位选择改为多选模式
- 增强拖拽视觉反馈(高亮、动画提示)
- 列表显示多个岗位标签

后端:
- 添加 position_ids 字段支持多岗位
- 兼容旧版 position_id 单选数据
- 返回 position_names 数组
This commit is contained in:
yuliang_guo
2026-01-30 16:19:40 +08:00
parent a92bfa2b0f
commit 920c6a64c8
4 changed files with 157 additions and 24 deletions

View File

@@ -226,7 +226,10 @@ class GrowthPath(BaseModel, SoftDeleteMixin):
# 岗位关联
position_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, comment="关联岗位ID"
Integer, nullable=True, comment="关联岗位ID兼容旧版优先使用position_ids"
)
position_ids: Mapped[Optional[List[int]]] = mapped_column(
JSON, nullable=True, comment="关联岗位ID列表支持多选"
)
# 路径配置(保留用于兼容,新版使用 nodes 关联表)

View File

@@ -56,7 +56,8 @@ class GrowthPathCreate(BaseModel):
name: str = Field(..., description="路径名称")
description: Optional[str] = Field(None, description="路径描述")
target_role: Optional[str] = Field(None, description="目标角色")
position_id: Optional[int] = Field(None, description="关联岗位ID")
position_id: Optional[int] = Field(None, description="关联岗位ID(兼容旧版)")
position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表支持多选")
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
is_active: bool = Field(True, description="是否启用")
@@ -70,6 +71,7 @@ class GrowthPathUpdate(BaseModel):
description: Optional[str] = None
target_role: Optional[str] = None
position_id: Optional[int] = None
position_ids: Optional[List[int]] = None
stages: Optional[List[StageConfig]] = None
estimated_duration_days: Optional[int] = None
is_active: Optional[bool] = None

View File

@@ -58,12 +58,21 @@ class GrowthPathService:
if existing.scalar_one_or_none():
raise ValueError(f"成长路径名称 '{data.name}' 已存在")
# 处理岗位关联:优先使用 position_ids兼容 position_id
position_ids = data.position_ids
position_id = data.position_id
if position_ids is None and position_id is not None:
position_ids = [position_id]
elif position_ids and not position_id:
position_id = position_ids[0] if position_ids else None
# 创建成长路径
growth_path = GrowthPath(
name=data.name,
description=data.description,
target_role=data.target_role,
position_id=data.position_id,
position_id=position_id,
position_ids=position_ids,
stages=[s.model_dump() for s in data.stages] if data.stages else None,
estimated_duration_days=data.estimated_duration_days,
is_active=data.is_active,
@@ -110,6 +119,12 @@ class GrowthPathService:
if 'stages' in update_data and update_data['stages']:
update_data['stages'] = [s.model_dump() if hasattr(s, 'model_dump') else s for s in update_data['stages']]
# 处理 position_ids 和 position_id 的兼容
if 'position_ids' in update_data and update_data['position_ids']:
update_data['position_id'] = update_data['position_ids'][0]
elif 'position_id' in update_data and update_data['position_id'] and 'position_ids' not in update_data:
update_data['position_ids'] = [update_data['position_id']]
for key, value in update_data.items():
setattr(growth_path, key, value)
@@ -173,12 +188,20 @@ class GrowthPathService:
if not growth_path:
return None
# 获取岗位名称
position_name = None
if growth_path.position_id:
position = await db.get(Position, growth_path.position_id)
# 获取岗位名称(支持多岗位)
position_ids = growth_path.position_ids or []
# 兼容旧数据
if not position_ids and growth_path.position_id:
position_ids = [growth_path.position_id]
position_names = []
for pid in position_ids:
position = await db.get(Position, pid)
if position:
position_name = position.name
position_names.append(position.name)
# 兼容旧版 position_name取第一个
position_name = position_names[0] if position_names else None
# 获取课程名称
nodes_data = []
@@ -208,7 +231,9 @@ class GrowthPathService:
"description": growth_path.description,
"target_role": growth_path.target_role,
"position_id": growth_path.position_id,
"position_ids": position_ids,
"position_name": position_name,
"position_names": position_names,
"stages": growth_path.stages,
"estimated_duration_days": growth_path.estimated_duration_days,
"is_active": growth_path.is_active,
@@ -250,12 +275,18 @@ class GrowthPathService:
items = []
for path in paths:
# 获取岗位名称
position_name = None
if path.position_id:
position = await db.get(Position, path.position_id)
# 获取岗位名称(支持多岗位)
position_ids = path.position_ids or []
if not position_ids and path.position_id:
position_ids = [path.position_id]
position_names = []
for pid in position_ids:
position = await db.get(Position, pid)
if position:
position_name = position.name
position_names.append(position.name)
position_name = position_names[0] if position_names else None
# 获取节点数量
node_count_result = await db.execute(
@@ -273,7 +304,9 @@ class GrowthPathService:
"name": path.name,
"description": path.description,
"position_id": path.position_id,
"position_ids": position_ids,
"position_name": position_name,
"position_names": position_names,
"is_active": path.is_active,
"node_count": node_count,
"estimated_duration_days": path.estimated_duration_days,

View File

@@ -53,11 +53,19 @@
</div>
</template>
</el-table-column>
<el-table-column prop="position_name" label="关联岗位" width="150">
<el-table-column prop="position_names" label="关联岗位" min-width="180">
<template #default="{ row }">
<el-tag v-if="row.position_name" type="primary" size="small">
{{ row.position_name }}
<template v-if="row.position_names && row.position_names.length > 0">
<el-tag
v-for="(name, idx) in row.position_names"
:key="idx"
type="primary"
size="small"
style="margin-right: 4px; margin-bottom: 4px;"
>
{{ name }}
</el-tag>
</template>
<span v-else class="text-muted">未关联</span>
</template>
</el-table-column>
@@ -149,8 +157,11 @@
</el-form-item>
<el-form-item label="关联岗位">
<el-select
v-model="editingPath.position_id"
placeholder="选择关联岗位(可选)"
v-model="editingPath.position_ids"
placeholder="选择关联岗位(可选)"
multiple
collapse-tags
collapse-tags-tooltip
clearable
style="width: 100%"
>
@@ -292,7 +303,9 @@
</div>
<div
class="selected-content"
@dragover.prevent
:class="{ 'is-dragging-over': isDraggingOver }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
@@ -421,7 +434,8 @@ interface EditingPath {
id?: number
name: string
description?: string
position_id?: number
position_id?: number // 单选(兼容旧数据)
position_ids: number[] // 多选(新数据)
stages: StageConfig[]
estimated_duration_days?: number
is_active: boolean
@@ -449,6 +463,8 @@ const filters = ref<{
// 编辑状态
const editingPath = ref<EditingPath | null>(null)
const courseSearch = ref('')
const isDragging = ref(false)
const isDraggingOver = ref(false)
// 基础数据
const positions = ref<Position[]>([])
@@ -552,6 +568,7 @@ const handleCreatePath = () => {
name: '',
description: '',
position_id: undefined,
position_ids: [],
stages: [
{ name: '入门阶段', order: 1 },
{ name: '提升阶段', order: 2 },
@@ -570,11 +587,20 @@ 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,
stages: detail.stages || [
{ name: '入门阶段', order: 1 },
{ name: '提升阶段', order: 2 },
@@ -637,7 +663,9 @@ const handleSavePath = async () => {
const payload = {
name: editingPath.value.name,
description: editingPath.value.description,
position_id: editingPath.value.position_id,
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,
@@ -779,8 +807,35 @@ const handleAddCourse = (course: Course) => {
* 拖拽开始
*/
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
}
}
/**
@@ -788,6 +843,9 @@ const handleDragStart = (event: DragEvent, course: Course) => {
*/
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
isDraggingOver.value = false
const courseData = event.dataTransfer!.getData('course')
if (!courseData) return
@@ -1030,6 +1088,31 @@ onMounted(() => {
flex: 1;
overflow-y: auto;
padding: 12px;
transition: all 0.3s ease;
}
.selected-content {
border: 2px dashed transparent;
&.is-dragging-over {
background: #ecf5ff;
border-color: #667eea;
&::before {
content: '松开鼠标添加课程';
display: block;
text-align: center;
padding: 12px;
color: #667eea;
font-weight: 500;
animation: pulse 1s infinite;
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.course-item {
@@ -1040,17 +1123,29 @@ onMounted(() => {
margin-bottom: 8px;
background: #f5f7fa;
border-radius: 6px;
cursor: pointer;
cursor: grab;
transition: all 0.2s;
border: 2px solid transparent;
&:hover {
background: #e6e8eb;
transform: translateX(4px);
border-color: #667eea;
}
&:active {
cursor: grabbing;
}
&.is-dragging {
opacity: 0.5;
border-color: #667eea;
}
&.is-added {
background: #f0f9eb;
border: 1px solid #67c23a;
border-color: #67c23a;
cursor: default;
}
.course-info {