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(
|
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 关联表)
|
# 路径配置(保留用于兼容,新版使用 nodes 关联表)
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class GrowthPathCreate(BaseModel):
|
|||||||
name: str = Field(..., description="路径名称")
|
name: str = Field(..., description="路径名称")
|
||||||
description: Optional[str] = Field(None, description="路径描述")
|
description: Optional[str] = Field(None, description="路径描述")
|
||||||
target_role: 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="阶段配置")
|
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
|
||||||
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
|
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
|
||||||
is_active: bool = Field(True, description="是否启用")
|
is_active: bool = Field(True, description="是否启用")
|
||||||
@@ -70,6 +71,7 @@ class GrowthPathUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
target_role: Optional[str] = None
|
target_role: Optional[str] = None
|
||||||
position_id: Optional[int] = None
|
position_id: Optional[int] = None
|
||||||
|
position_ids: Optional[List[int]] = None
|
||||||
stages: Optional[List[StageConfig]] = None
|
stages: Optional[List[StageConfig]] = None
|
||||||
estimated_duration_days: Optional[int] = None
|
estimated_duration_days: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|||||||
@@ -58,12 +58,21 @@ class GrowthPathService:
|
|||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise ValueError(f"成长路径名称 '{data.name}' 已存在")
|
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(
|
growth_path = GrowthPath(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
target_role=data.target_role,
|
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,
|
stages=[s.model_dump() for s in data.stages] if data.stages else None,
|
||||||
estimated_duration_days=data.estimated_duration_days,
|
estimated_duration_days=data.estimated_duration_days,
|
||||||
is_active=data.is_active,
|
is_active=data.is_active,
|
||||||
@@ -110,6 +119,12 @@ class GrowthPathService:
|
|||||||
if 'stages' in update_data and update_data['stages']:
|
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']]
|
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():
|
for key, value in update_data.items():
|
||||||
setattr(growth_path, key, value)
|
setattr(growth_path, key, value)
|
||||||
|
|
||||||
@@ -173,12 +188,20 @@ class GrowthPathService:
|
|||||||
if not growth_path:
|
if not growth_path:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 获取岗位名称
|
# 获取岗位名称(支持多岗位)
|
||||||
position_name = None
|
position_ids = growth_path.position_ids or []
|
||||||
if growth_path.position_id:
|
# 兼容旧数据
|
||||||
position = await db.get(Position, growth_path.position_id)
|
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:
|
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 = []
|
nodes_data = []
|
||||||
@@ -208,7 +231,9 @@ class GrowthPathService:
|
|||||||
"description": growth_path.description,
|
"description": growth_path.description,
|
||||||
"target_role": growth_path.target_role,
|
"target_role": growth_path.target_role,
|
||||||
"position_id": growth_path.position_id,
|
"position_id": growth_path.position_id,
|
||||||
|
"position_ids": position_ids,
|
||||||
"position_name": position_name,
|
"position_name": position_name,
|
||||||
|
"position_names": position_names,
|
||||||
"stages": growth_path.stages,
|
"stages": growth_path.stages,
|
||||||
"estimated_duration_days": growth_path.estimated_duration_days,
|
"estimated_duration_days": growth_path.estimated_duration_days,
|
||||||
"is_active": growth_path.is_active,
|
"is_active": growth_path.is_active,
|
||||||
@@ -250,12 +275,18 @@ class GrowthPathService:
|
|||||||
|
|
||||||
items = []
|
items = []
|
||||||
for path in paths:
|
for path in paths:
|
||||||
# 获取岗位名称
|
# 获取岗位名称(支持多岗位)
|
||||||
position_name = None
|
position_ids = path.position_ids or []
|
||||||
if path.position_id:
|
if not position_ids and path.position_id:
|
||||||
position = await db.get(Position, path.position_id)
|
position_ids = [path.position_id]
|
||||||
|
|
||||||
|
position_names = []
|
||||||
|
for pid in position_ids:
|
||||||
|
position = await db.get(Position, pid)
|
||||||
if position:
|
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(
|
node_count_result = await db.execute(
|
||||||
@@ -273,7 +304,9 @@ class GrowthPathService:
|
|||||||
"name": path.name,
|
"name": path.name,
|
||||||
"description": path.description,
|
"description": path.description,
|
||||||
"position_id": path.position_id,
|
"position_id": path.position_id,
|
||||||
|
"position_ids": position_ids,
|
||||||
"position_name": position_name,
|
"position_name": position_name,
|
||||||
|
"position_names": position_names,
|
||||||
"is_active": path.is_active,
|
"is_active": path.is_active,
|
||||||
"node_count": node_count,
|
"node_count": node_count,
|
||||||
"estimated_duration_days": path.estimated_duration_days,
|
"estimated_duration_days": path.estimated_duration_days,
|
||||||
|
|||||||
@@ -53,11 +53,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.position_name" type="primary" size="small">
|
<template v-if="row.position_names && row.position_names.length > 0">
|
||||||
{{ row.position_name }}
|
<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>
|
</el-tag>
|
||||||
|
</template>
|
||||||
<span v-else class="text-muted">未关联</span>
|
<span v-else class="text-muted">未关联</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -149,8 +157,11 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="关联岗位">
|
<el-form-item label="关联岗位">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="editingPath.position_id"
|
v-model="editingPath.position_ids"
|
||||||
placeholder="选择关联岗位(可选)"
|
placeholder="选择关联岗位(可多选)"
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
clearable
|
clearable
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
@@ -292,7 +303,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="selected-content"
|
class="selected-content"
|
||||||
@dragover.prevent
|
:class="{ 'is-dragging-over': isDraggingOver }"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
>
|
>
|
||||||
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
|
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
|
||||||
@@ -421,7 +434,8 @@ interface EditingPath {
|
|||||||
id?: number
|
id?: number
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
position_id?: number
|
position_id?: number // 单选(兼容旧数据)
|
||||||
|
position_ids: number[] // 多选(新数据)
|
||||||
stages: StageConfig[]
|
stages: StageConfig[]
|
||||||
estimated_duration_days?: number
|
estimated_duration_days?: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -449,6 +463,8 @@ const filters = ref<{
|
|||||||
// 编辑状态
|
// 编辑状态
|
||||||
const editingPath = ref<EditingPath | null>(null)
|
const editingPath = ref<EditingPath | null>(null)
|
||||||
const courseSearch = ref('')
|
const courseSearch = ref('')
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const isDraggingOver = ref(false)
|
||||||
|
|
||||||
// 基础数据
|
// 基础数据
|
||||||
const positions = ref<Position[]>([])
|
const positions = ref<Position[]>([])
|
||||||
@@ -552,6 +568,7 @@ const handleCreatePath = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
position_id: undefined,
|
position_id: undefined,
|
||||||
|
position_ids: [],
|
||||||
stages: [
|
stages: [
|
||||||
{ name: '入门阶段', order: 1 },
|
{ name: '入门阶段', order: 1 },
|
||||||
{ name: '提升阶段', order: 2 },
|
{ name: '提升阶段', order: 2 },
|
||||||
@@ -570,11 +587,20 @@ const handleEditPath = async (row: GrowthPathListItem) => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const detail = await getGrowthPathDetail(row.id)
|
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 = {
|
editingPath.value = {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
name: detail.name,
|
name: detail.name,
|
||||||
description: detail.description,
|
description: detail.description,
|
||||||
position_id: detail.position_id,
|
position_id: detail.position_id,
|
||||||
|
position_ids: positionIds,
|
||||||
stages: detail.stages || [
|
stages: detail.stages || [
|
||||||
{ name: '入门阶段', order: 1 },
|
{ name: '入门阶段', order: 1 },
|
||||||
{ name: '提升阶段', order: 2 },
|
{ name: '提升阶段', order: 2 },
|
||||||
@@ -637,7 +663,9 @@ const handleSavePath = async () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: editingPath.value.name,
|
name: editingPath.value.name,
|
||||||
description: editingPath.value.description,
|
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,
|
stages: editingPath.value.stages,
|
||||||
estimated_duration_days: editingPath.value.estimated_duration_days,
|
estimated_duration_days: editingPath.value.estimated_duration_days,
|
||||||
is_active: editingPath.value.is_active,
|
is_active: editingPath.value.is_active,
|
||||||
@@ -779,8 +807,35 @@ const handleAddCourse = (course: Course) => {
|
|||||||
* 拖拽开始
|
* 拖拽开始
|
||||||
*/
|
*/
|
||||||
const handleDragStart = (event: DragEvent, course: Course) => {
|
const handleDragStart = (event: DragEvent, course: Course) => {
|
||||||
|
isDragging.value = true
|
||||||
event.dataTransfer!.effectAllowed = 'copy'
|
event.dataTransfer!.effectAllowed = 'copy'
|
||||||
event.dataTransfer!.setData('course', JSON.stringify(course))
|
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) => {
|
const handleDrop = (event: DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
isDragging.value = false
|
||||||
|
isDraggingOver.value = false
|
||||||
|
|
||||||
const courseData = event.dataTransfer!.getData('course')
|
const courseData = event.dataTransfer!.getData('course')
|
||||||
if (!courseData) return
|
if (!courseData) return
|
||||||
|
|
||||||
@@ -1030,6 +1088,31 @@ onMounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
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 {
|
.course-item {
|
||||||
@@ -1040,17 +1123,29 @@ onMounted(() => {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #e6e8eb;
|
background: #e6e8eb;
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-added {
|
&.is-added {
|
||||||
background: #f0f9eb;
|
background: #f0f9eb;
|
||||||
border: 1px solid #67c23a;
|
border-color: #67c23a;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-info {
|
.course-info {
|
||||||
|
|||||||
Reference in New Issue
Block a user