Files
012-kaopeilian/frontend/src/views/manager/growth-path-management.vue
yuliang_guo 0b7c07eb7f
All checks were successful
continuous-integration/drone/push Build is passing
feat: 添加请求验证错误详细日志
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-01-31 10:03:54 +08:00

1438 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>