feat: 成长路径支持多岗位关联 + 增强拖拽功能
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
前端: - 岗位选择改为多选模式 - 增强拖拽视觉反馈(高亮、动画提示) - 列表显示多个岗位标签 后端: - 添加 position_ids 字段支持多岗位 - 兼容旧版 position_id 单选数据 - 返回 position_names 数组
This commit is contained in:
@@ -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 关联表)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user