All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
1438 lines
39 KiB
Vue
1438 lines
39 KiB
Vue
<template>
|
||
<div class="growth-path-management-container">
|
||
<!-- 路径列表视图 -->
|
||
<template v-if="!editingPath">
|
||
<div class="page-header">
|
||
<h1 class="page-title">成长路径管理</h1>
|
||
<div class="header-actions">
|
||
<el-select
|
||
v-model="filters.position_id"
|
||
placeholder="筛选岗位"
|
||
clearable
|
||
style="width: 180px"
|
||
@change="loadGrowthPaths"
|
||
>
|
||
<el-option label="全部岗位" :value="undefined" />
|
||
<el-option
|
||
v-for="pos in positions"
|
||
:key="pos.id"
|
||
:label="pos.name"
|
||
:value="pos.id"
|
||
/>
|
||
</el-select>
|
||
<el-select
|
||
v-model="filters.is_active"
|
||
placeholder="状态"
|
||
clearable
|
||
style="width: 120px"
|
||
@change="loadGrowthPaths"
|
||
>
|
||
<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>
|
||
新建路径
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 路径列表 -->
|
||
<div class="path-list-card">
|
||
<el-table
|
||
:data="growthPaths"
|
||
v-loading="loading"
|
||
stripe
|
||
>
|
||
<el-table-column prop="name" label="路径名称" min-width="200">
|
||
<template #default="{ row }">
|
||
<div class="path-name-cell">
|
||
<span class="path-name">{{ row.name }}</span>
|
||
<el-tag v-if="!row.is_active" size="small" type="info">已禁用</el-tag>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="position_names" label="关联岗位" min-width="180">
|
||
<template #default="{ row }">
|
||
<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>
|
||
<el-table-column prop="node_count" label="课程数" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<span class="node-count">{{ row.node_count || 0 }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<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>
|
||
<el-button link type="primary" @click="handleToggleStatus(row)">
|
||
{{ row.is_active ? '禁用' : '启用' }}
|
||
</el-button>
|
||
<el-button link type="danger" @click="handleDeletePath(row)">
|
||
删除
|
||
</el-button>
|
||
</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>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 路径编辑视图 -->
|
||
<template v-else>
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<el-button link @click="handleBack">
|
||
<el-icon><ArrowLeft /></el-icon>
|
||
返回列表
|
||
</el-button>
|
||
<h1 class="page-title">{{ editingPath.id ? '编辑成长路径' : '新建成长路径' }}</h1>
|
||
</div>
|
||
<div class="header-actions">
|
||
<el-button @click="handleBack">取消</el-button>
|
||
<el-button type="primary" @click="handleSavePath" :loading="saving">
|
||
<el-icon class="el-icon--left"><Check /></el-icon>
|
||
保存
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="path-editor-new">
|
||
<!-- 上方:基本信息 + 学习阶段 + 路径统计 -->
|
||
<div class="editor-top">
|
||
<!-- 基本信息 -->
|
||
<div class="top-section basic-info">
|
||
<h3>基本信息</h3>
|
||
<el-form label-position="top" :model="editingPath" size="small">
|
||
<div class="form-row">
|
||
<el-form-item label="路径名称" required class="form-item-half">
|
||
<el-input v-model="editingPath.name" placeholder="请输入路径名称" />
|
||
</el-form-item>
|
||
<el-form-item label="关联岗位" class="form-item-half">
|
||
<div class="position-select-wrapper">
|
||
<el-select
|
||
v-model="editingPath.position_ids"
|
||
placeholder="选择关联岗位"
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<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>
|
||
</div>
|
||
<div class="form-row">
|
||
<el-form-item label="路径描述" class="form-item-full">
|
||
<el-input
|
||
v-model="editingPath.description"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="请输入路径描述"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<el-form-item label="预计天数" class="form-item-small">
|
||
<el-input-number
|
||
v-model="editingPath.estimated_duration_days"
|
||
:min="1"
|
||
:max="365"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="状态" class="form-item-small">
|
||
<el-switch
|
||
v-model="editingPath.is_active"
|
||
active-text="启用"
|
||
inactive-text="禁用"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
</div>
|
||
|
||
<!-- 学习阶段 -->
|
||
<div class="top-section stages-section">
|
||
<div class="section-header">
|
||
<h3>学习阶段</h3>
|
||
<el-button link type="primary" size="small" @click="addStage">
|
||
<el-icon><Plus /></el-icon> 添加
|
||
</el-button>
|
||
</div>
|
||
<div class="stages-list-horizontal">
|
||
<div
|
||
v-for="(stage, index) in editingPath.stages"
|
||
:key="index"
|
||
class="stage-tag"
|
||
>
|
||
<el-input
|
||
v-model="stage.name"
|
||
placeholder="阶段名称"
|
||
size="small"
|
||
style="width: 100px"
|
||
>
|
||
<template #prefix>
|
||
<span class="stage-order">{{ index + 1 }}</span>
|
||
</template>
|
||
</el-input>
|
||
<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>
|
||
|
||
<!-- 路径统计 -->
|
||
<div class="top-section stats-section">
|
||
<h3>路径统计</h3>
|
||
<div class="stats-row">
|
||
<div class="stat-box">
|
||
<span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span>
|
||
<span class="stat-label">课程总数</span>
|
||
</div>
|
||
<div class="stat-box">
|
||
<span class="stat-value">{{ requiredCount }}</span>
|
||
<span class="stat-label">必修课程</span>
|
||
</div>
|
||
<div class="stat-box">
|
||
<span class="stat-value">{{ totalDuration }}</span>
|
||
<span class="stat-label">总学时(h)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 下方:左右分栏 -->
|
||
<div class="editor-bottom">
|
||
<!-- 左侧 1/3:可选课程 -->
|
||
<div class="course-library-panel">
|
||
<div class="panel-header">
|
||
<span>可选课程</span>
|
||
<el-tag size="small">{{ filteredCourses.length }} 门</el-tag>
|
||
</div>
|
||
<div class="course-filter">
|
||
<el-input
|
||
v-model="courseSearch"
|
||
placeholder="搜索课程..."
|
||
clearable
|
||
size="small"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
<el-select
|
||
v-model="courseCategory"
|
||
placeholder="分类"
|
||
clearable
|
||
size="small"
|
||
style="width: 100px"
|
||
>
|
||
<el-option
|
||
v-for="cat in courseCategories"
|
||
:key="cat"
|
||
:label="cat"
|
||
:value="cat"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
<div class="course-list" v-loading="coursesLoading">
|
||
<div
|
||
v-for="course in filteredCourses"
|
||
:key="course.id"
|
||
class="course-item"
|
||
:class="{ 'is-added': isNodeAdded(course.id) }"
|
||
draggable="true"
|
||
@dragstart="handleDragStart($event, course)"
|
||
@click="handleAddCourse(course)"
|
||
>
|
||
<div class="course-info">
|
||
<span class="course-name">{{ course.name || course.title }}</span>
|
||
<span class="course-meta">
|
||
{{ course.duration_hours || course.estimatedDuration || 0 }}h · {{ course.category || '未分类' }}
|
||
</span>
|
||
</div>
|
||
<el-icon v-if="isNodeAdded(course.id)" color="#67c23a"><Check /></el-icon>
|
||
<el-icon v-else><Plus /></el-icon>
|
||
</div>
|
||
<el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧 2/3:已选课程配置 -->
|
||
<div class="selected-courses-panel">
|
||
<div class="panel-header">
|
||
<span>已选课程配置</span>
|
||
<el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} 门</el-tag>
|
||
</div>
|
||
<div
|
||
class="selected-content"
|
||
:class="{ 'is-dragging-over': isDraggingOver }"
|
||
@dragover.prevent="handleDragOver"
|
||
@dragleave="handleDragLeave"
|
||
@drop="handleDrop"
|
||
>
|
||
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
|
||
<div
|
||
v-for="(stage, stageIndex) in editingPath.stages"
|
||
:key="stageIndex"
|
||
class="stage-section"
|
||
>
|
||
<div class="stage-header">
|
||
<span>{{ stage.name }}</span>
|
||
<el-tag size="small" type="info">
|
||
{{ getStageNodes(stage.name).length }} 门
|
||
</el-tag>
|
||
</div>
|
||
<div class="stage-nodes">
|
||
<div
|
||
v-for="(node, nodeIndex) in getStageNodes(stage.name)"
|
||
:key="node.course_id"
|
||
class="node-item"
|
||
>
|
||
<div class="node-drag-handle">
|
||
<el-icon><Rank /></el-icon>
|
||
</div>
|
||
<div class="node-content">
|
||
<span class="node-title">{{ node.title }}</span>
|
||
<div class="node-meta">
|
||
<el-tag
|
||
:type="node.is_required ? 'danger' : 'info'"
|
||
size="small"
|
||
@click="toggleRequired(node)"
|
||
style="cursor: pointer"
|
||
>
|
||
{{ node.is_required ? '必修' : '选修' }}
|
||
</el-tag>
|
||
<span>{{ node.estimated_days || 1 }}天</span>
|
||
</div>
|
||
</div>
|
||
<div class="node-actions">
|
||
<el-select
|
||
v-model="node.stage_name"
|
||
size="small"
|
||
style="width: 90px"
|
||
placeholder="阶段"
|
||
>
|
||
<el-option
|
||
v-for="s in editingPath.stages"
|
||
:key="s.name"
|
||
:label="s.name"
|
||
:value="s.name"
|
||
/>
|
||
</el-select>
|
||
<el-button
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
@click="removeNode(node)"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="getStageNodes(stage.name).length === 0"
|
||
class="stage-empty"
|
||
>
|
||
拖拽或点击课程添加到此阶段
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<el-empty v-else description="请从左侧添加课程" :image-size="60">
|
||
<template #description>
|
||
<p>点击或拖拽左侧课程添加</p>
|
||
</template>
|
||
</el-empty>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import {
|
||
Plus, ArrowLeft, Check, Delete, Search, Rank
|
||
} from '@element-plus/icons-vue'
|
||
import {
|
||
getGrowthPathConfigs,
|
||
getGrowthPathDetail,
|
||
createGrowthPath,
|
||
updateGrowthPath,
|
||
deleteGrowthPath,
|
||
getManagerCourses,
|
||
type GrowthPathListItem,
|
||
type GrowthPathConfig,
|
||
type CreateGrowthPathNode,
|
||
type StageConfig,
|
||
} from '@/api/manager'
|
||
import request from '@/api/request'
|
||
|
||
// 岗位接口
|
||
interface Position {
|
||
id: number
|
||
name: string
|
||
code: string
|
||
status: string
|
||
}
|
||
|
||
// 课程接口
|
||
interface Course {
|
||
id: number
|
||
name?: string
|
||
title?: string
|
||
description?: string
|
||
category?: string
|
||
duration_hours?: number
|
||
estimatedDuration?: number
|
||
}
|
||
|
||
// 编辑用的路径结构
|
||
interface EditingPath {
|
||
id?: number
|
||
name: string
|
||
description?: string
|
||
position_id?: number // 单选(兼容旧数据)
|
||
position_ids: number[] // 多选(新数据)
|
||
stages: StageConfig[]
|
||
estimated_duration_days?: number
|
||
is_active: boolean
|
||
sort_order?: number
|
||
nodes: CreateGrowthPathNode[]
|
||
}
|
||
|
||
// ========== 状态 ==========
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const coursesLoading = ref(false)
|
||
|
||
// 路径列表
|
||
const growthPaths = ref<GrowthPathListItem[]>([])
|
||
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 isDragging = ref(false)
|
||
const isDraggingOver = ref(false)
|
||
|
||
// 基础数据
|
||
const positions = ref<Position[]>([])
|
||
const courses = ref<Course[]>([])
|
||
|
||
// ========== 计算属性 ==========
|
||
// 获取所有课程分类
|
||
const courseCategories = computed(() => {
|
||
const categories = new Set<string>()
|
||
courses.value.forEach(c => {
|
||
if (c.category) categories.add(c.category)
|
||
})
|
||
return Array.from(categories).sort()
|
||
})
|
||
|
||
const filteredCourses = computed(() => {
|
||
let result = courses.value
|
||
|
||
// 按分类筛选
|
||
if (courseCategory.value) {
|
||
result = result.filter(c => c.category === courseCategory.value)
|
||
}
|
||
|
||
// 按关键词搜索
|
||
if (courseSearch.value) {
|
||
const keyword = courseSearch.value.toLowerCase()
|
||
result = result.filter(c =>
|
||
(c.name || c.title || '').toLowerCase().includes(keyword) ||
|
||
(c.category || '').toLowerCase().includes(keyword)
|
||
)
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
const requiredCount = computed(() => {
|
||
return editingPath.value?.nodes?.filter(n => n.is_required).length || 0
|
||
})
|
||
|
||
const totalDuration = computed(() => {
|
||
if (!editingPath.value?.nodes) return 0
|
||
return editingPath.value.nodes.reduce((sum, node) => {
|
||
const course = courses.value.find(c => c.id === node.course_id)
|
||
return sum + (course?.duration_hours || course?.estimatedDuration || 0)
|
||
}, 0)
|
||
})
|
||
|
||
const isAllPositionsSelected = computed(() => {
|
||
if (!editingPath.value?.position_ids || positions.value.length === 0) return false
|
||
return editingPath.value.position_ids.length === positions.value.length
|
||
})
|
||
|
||
// ========== 方法 ==========
|
||
|
||
/**
|
||
* 格式化日期时间
|
||
*/
|
||
const formatDateTime = (dateStr: string) => {
|
||
if (!dateStr) return '-'
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 加载岗位列表
|
||
*/
|
||
const loadPositions = async () => {
|
||
try {
|
||
const res = await request.get<{ data: { items: Position[] } }>('/api/v1/admin/positions', {
|
||
params: { page_size: 100 }
|
||
})
|
||
positions.value = res.data?.items || []
|
||
} catch (error) {
|
||
console.error('加载岗位列表失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载课程列表
|
||
*/
|
||
const loadCourses = async () => {
|
||
coursesLoading.value = true
|
||
try {
|
||
// 后端限制 size 最大 100
|
||
const res = await getManagerCourses({ page: 1, size: 100 })
|
||
// API返回格式: { code, message, data: { items, total, page, size } }
|
||
courses.value = res.data?.items || res.items || []
|
||
console.log('加载课程列表成功:', courses.value.length, '门课程')
|
||
} catch (error) {
|
||
console.error('加载课程列表失败:', error)
|
||
} finally {
|
||
coursesLoading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载成长路径列表
|
||
*/
|
||
const loadGrowthPaths = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await getGrowthPathConfigs({
|
||
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)
|
||
ElMessage.error('加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建新路径
|
||
*/
|
||
const handleCreatePath = () => {
|
||
editingPath.value = {
|
||
name: '',
|
||
description: '',
|
||
position_id: undefined,
|
||
position_ids: [],
|
||
stages: [
|
||
{ name: '入门阶段', order: 1 },
|
||
{ name: '提升阶段', order: 2 },
|
||
{ name: '进阶阶段', order: 3 },
|
||
],
|
||
estimated_duration_days: 30,
|
||
is_active: true,
|
||
nodes: [],
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 编辑路径
|
||
*/
|
||
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 },
|
||
{ name: '进阶阶段', order: 3 },
|
||
],
|
||
estimated_duration_days: detail.estimated_duration_days,
|
||
is_active: detail.is_active,
|
||
sort_order: detail.sort_order,
|
||
nodes: detail.nodes?.map(n => ({
|
||
course_id: n.course_id,
|
||
stage_name: n.stage_name,
|
||
title: n.title,
|
||
description: n.description,
|
||
order_num: n.order_num,
|
||
is_required: n.is_required,
|
||
prerequisites: n.prerequisites,
|
||
estimated_days: n.estimated_days,
|
||
})) || [],
|
||
}
|
||
} catch (error) {
|
||
console.error('加载路径详情失败:', error)
|
||
ElMessage.error('加载路径详情失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 返回列表
|
||
*/
|
||
const handleBack = () => {
|
||
ElMessageBox.confirm('确定要返回吗?未保存的更改将丢失。', '提示', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
}).then(() => {
|
||
editingPath.value = null
|
||
}).catch(() => {})
|
||
}
|
||
|
||
/**
|
||
* 保存路径
|
||
*/
|
||
const handleSavePath = async () => {
|
||
if (!editingPath.value) return
|
||
|
||
if (!editingPath.value.name?.trim()) {
|
||
ElMessage.warning('请输入路径名称')
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
try {
|
||
// 重新计算 order_num
|
||
const nodesWithOrder = editingPath.value.nodes.map((node, index) => ({
|
||
...node,
|
||
order_num: index + 1,
|
||
}))
|
||
|
||
const payload = {
|
||
name: editingPath.value.name,
|
||
description: editingPath.value.description,
|
||
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,
|
||
sort_order: editingPath.value.sort_order || 0,
|
||
nodes: nodesWithOrder,
|
||
}
|
||
|
||
if (editingPath.value.id) {
|
||
await updateGrowthPath(editingPath.value.id, payload)
|
||
ElMessage.success('更新成功')
|
||
} else {
|
||
await createGrowthPath(payload)
|
||
ElMessage.success('创建成功')
|
||
}
|
||
|
||
editingPath.value = null
|
||
loadGrowthPaths()
|
||
} catch (error: any) {
|
||
console.error('保存失败:', error)
|
||
ElMessage.error(error.message || '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换启用状态
|
||
*/
|
||
const handleToggleStatus = async (row: GrowthPathListItem) => {
|
||
try {
|
||
await updateGrowthPath(row.id, { is_active: !row.is_active })
|
||
ElMessage.success(row.is_active ? '已禁用' : '已启用')
|
||
loadGrowthPaths()
|
||
} catch (error) {
|
||
console.error('切换状态失败:', error)
|
||
ElMessage.error('操作失败')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除路径
|
||
*/
|
||
const handleDeletePath = async (row: GrowthPathListItem) => {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定要删除路径"${row.name}"吗?此操作不可恢复。`,
|
||
'删除确认',
|
||
{
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
}
|
||
)
|
||
await deleteGrowthPath(row.id)
|
||
ElMessage.success('删除成功')
|
||
loadGrowthPaths()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel') {
|
||
console.error('删除失败:', error)
|
||
ElMessage.error('删除失败')
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 添加阶段
|
||
*/
|
||
const addStage = () => {
|
||
if (!editingPath.value) return
|
||
const order = editingPath.value.stages.length + 1
|
||
editingPath.value.stages.push({
|
||
name: `阶段${order}`,
|
||
order,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 移除阶段
|
||
*/
|
||
const removeStage = (index: number) => {
|
||
if (!editingPath.value) return
|
||
const stageName = editingPath.value.stages[index].name
|
||
|
||
// 检查是否有课程在此阶段
|
||
const hasNodes = editingPath.value.nodes.some(n => n.stage_name === stageName)
|
||
if (hasNodes) {
|
||
ElMessage.warning('请先移除该阶段下的所有课程')
|
||
return
|
||
}
|
||
|
||
editingPath.value.stages.splice(index, 1)
|
||
}
|
||
|
||
/**
|
||
* 课程搜索
|
||
*/
|
||
const handleCourseSearch = () => {
|
||
// 由计算属性自动处理
|
||
}
|
||
|
||
/**
|
||
* 检查课程是否已添加
|
||
*/
|
||
const isNodeAdded = (courseId: number) => {
|
||
return editingPath.value?.nodes?.some(n => n.course_id === courseId)
|
||
}
|
||
|
||
/**
|
||
* 获取某阶段的节点
|
||
*/
|
||
const getStageNodes = (stageName: string) => {
|
||
return editingPath.value?.nodes?.filter(n => n.stage_name === stageName) || []
|
||
}
|
||
|
||
/**
|
||
* 全选/取消全选岗位
|
||
*/
|
||
const handleSelectAllPositions = () => {
|
||
if (!editingPath.value) return
|
||
const allIds = positions.value.map(p => p.id)
|
||
// 如果当前已全选,则取消全选
|
||
if (editingPath.value.position_ids?.length === allIds.length) {
|
||
editingPath.value.position_ids = []
|
||
} else {
|
||
editingPath.value.position_ids = [...allIds]
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 添加课程
|
||
*/
|
||
const handleAddCourse = (course: Course) => {
|
||
if (!editingPath.value) return
|
||
if (isNodeAdded(course.id)) {
|
||
ElMessage.warning('该课程已添加')
|
||
return
|
||
}
|
||
|
||
const defaultStage = editingPath.value.stages[0]?.name || '入门阶段'
|
||
editingPath.value.nodes.push({
|
||
course_id: course.id,
|
||
stage_name: defaultStage,
|
||
title: course.name || course.title || '',
|
||
description: course.description,
|
||
order_num: editingPath.value.nodes.length + 1,
|
||
is_required: true,
|
||
estimated_days: Math.ceil((course.duration_hours || course.estimatedDuration || 2) / 2),
|
||
})
|
||
ElMessage.success('已添加')
|
||
}
|
||
|
||
/**
|
||
* 拖拽开始
|
||
*/
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 放置处理
|
||
*/
|
||
const handleDrop = (event: DragEvent) => {
|
||
event.preventDefault()
|
||
isDragging.value = false
|
||
isDraggingOver.value = false
|
||
|
||
const courseData = event.dataTransfer!.getData('course')
|
||
if (!courseData) return
|
||
|
||
const course = JSON.parse(courseData) as Course
|
||
handleAddCourse(course)
|
||
}
|
||
|
||
/**
|
||
* 切换必修/选修
|
||
*/
|
||
const toggleRequired = (node: CreateGrowthPathNode) => {
|
||
node.is_required = !node.is_required
|
||
}
|
||
|
||
/**
|
||
* 移除节点
|
||
*/
|
||
const removeNode = (node: CreateGrowthPathNode) => {
|
||
if (!editingPath.value) return
|
||
const index = editingPath.value.nodes.findIndex(n => n.course_id === node.course_id)
|
||
if (index > -1) {
|
||
editingPath.value.nodes.splice(index, 1)
|
||
ElMessage.success('已移除')
|
||
}
|
||
}
|
||
|
||
// ========== 生命周期 ==========
|
||
onMounted(() => {
|
||
loadPositions()
|
||
loadCourses()
|
||
loadGrowthPaths()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.growth-path-management-container {
|
||
padding: 20px;
|
||
height: calc(100vh - 60px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
flex-shrink: 0;
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin: 0;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
}
|
||
|
||
.path-list-card {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
padding: 20px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.el-table {
|
||
flex: 1;
|
||
}
|
||
|
||
.path-name-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.path-name {
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.node-count {
|
||
font-weight: 600;
|
||
color: #667eea;
|
||
}
|
||
|
||
.text-muted {
|
||
color: #909399;
|
||
}
|
||
|
||
.pagination-wrapper {
|
||
margin-top: 16px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
|
||
// 新布局样式
|
||
.path-editor-new {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-height: 0;
|
||
|
||
// 上方区域
|
||
.editor-top {
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-shrink: 0;
|
||
|
||
.top-section {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
padding: 16px;
|
||
|
||
h3 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin: 0 0 12px 0;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
}
|
||
|
||
.basic-info {
|
||
flex: 2;
|
||
|
||
.form-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 8px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.form-item-half {
|
||
flex: 1;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.form-item-full {
|
||
flex: 1;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.form-item-small {
|
||
width: 120px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.position-select-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.el-select {
|
||
flex: 1;
|
||
}
|
||
|
||
.select-all-btn {
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.stages-section {
|
||
flex: 1;
|
||
min-width: 280px;
|
||
|
||
.section-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
|
||
h3 {
|
||
margin: 0;
|
||
padding: 0;
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.stages-list-horizontal {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
|
||
.stage-tag {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
|
||
.stage-order {
|
||
color: #667eea;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.stats-section {
|
||
width: 200px;
|
||
flex-shrink: 0;
|
||
|
||
.stats-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
|
||
.stat-box {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 8px 4px;
|
||
background: #f5f7fa;
|
||
border-radius: 6px;
|
||
|
||
.stat-value {
|
||
display: block;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #667eea;
|
||
}
|
||
|
||
.stat-label {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #909399;
|
||
margin-top: 2px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 下方区域
|
||
.editor-bottom {
|
||
flex: 2;
|
||
display: flex;
|
||
gap: 16px;
|
||
min-height: 400px;
|
||
|
||
// 左侧课程库 1/3
|
||
.course-library-panel {
|
||
width: 33%;
|
||
min-width: 280px;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: #fafafa;
|
||
border-radius: 8px 8px 0 0;
|
||
font-weight: 500;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
|
||
.course-filter {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
background: #f5f7fa;
|
||
border-bottom: 1px solid #ebeef5;
|
||
|
||
.el-input {
|
||
flex: 1;
|
||
}
|
||
}
|
||
|
||
.course-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
|
||
.course-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 10px;
|
||
margin-bottom: 6px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
cursor: grab;
|
||
transition: all 0.15s;
|
||
border: 1px solid #e4e7ed;
|
||
|
||
&:hover {
|
||
background: #e6e8eb;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
&:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
&.is-added {
|
||
background: #f0f9eb;
|
||
border-color: #67c23a;
|
||
cursor: default;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.course-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
.course-name {
|
||
display: block;
|
||
font-size: 13px;
|
||
color: #333;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.course-meta {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #909399;
|
||
margin-top: 2px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 右侧已选课程 2/3
|
||
.selected-courses-panel {
|
||
flex: 1;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: #fafafa;
|
||
border-radius: 8px 8px 0 0;
|
||
font-weight: 500;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
|
||
.selected-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
border: 2px dashed transparent;
|
||
transition: all 0.3s ease;
|
||
|
||
&.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;
|
||
}
|
||
}
|
||
|
||
.stage-section {
|
||
margin-bottom: 16px;
|
||
|
||
.stage-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
border-radius: 6px 6px 0 0;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.stage-nodes {
|
||
background: #fafafa;
|
||
border: 1px solid #ebeef5;
|
||
border-top: none;
|
||
border-radius: 0 0 6px 6px;
|
||
padding: 8px;
|
||
min-height: 50px;
|
||
|
||
.node-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
background: #fff;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 6px;
|
||
margin-bottom: 8px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.node-drag-handle {
|
||
color: #c0c4cc;
|
||
cursor: move;
|
||
}
|
||
|
||
.node-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
.node-title {
|
||
display: block;
|
||
font-weight: 500;
|
||
color: #333;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.node-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
}
|
||
|
||
.node-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
}
|
||
|
||
.stage-empty {
|
||
text-align: center;
|
||
padding: 16px;
|
||
color: #909399;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.6; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 响应式
|
||
@media (max-width: 1200px) {
|
||
.growth-path-management-container {
|
||
.path-editor-new {
|
||
.editor-top {
|
||
flex-wrap: wrap;
|
||
|
||
.basic-info {
|
||
width: 100%;
|
||
flex: none;
|
||
}
|
||
|
||
.stages-section,
|
||
.stats-section {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
}
|
||
}
|
||
|
||
.editor-bottom {
|
||
flex-direction: column;
|
||
|
||
.course-library-panel {
|
||
width: 100%;
|
||
max-height: 300px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|