Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,10 +2,11 @@
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -131,6 +132,20 @@ os.makedirs(upload_path, exist_ok=True)
|
|||||||
app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads")
|
app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads")
|
||||||
|
|
||||||
|
|
||||||
|
# 请求验证错误处理 (422)
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
"""处理请求验证错误,记录详细日志"""
|
||||||
|
logger.error(f"请求验证错误 [{request.method} {request.url.path}]: {exc.errors()}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"detail": exc.errors(),
|
||||||
|
"body": exc.body if hasattr(exc, 'body') else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# 全局异常处理
|
# 全局异常处理
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request, exc):
|
async def global_exception_handler(request, exc):
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<div class="growth-path-management-container">
|
<div class="growth-path-management-container">
|
||||||
<!-- 路径列表视图 -->
|
<!-- 路径列表视图 -->
|
||||||
<template v-if="!editingPath">
|
<template v-if="!editingPath">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">成长路径管理</h1>
|
<h1 class="page-title">成长路径管理</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.position_id"
|
v-model="filters.position_id"
|
||||||
placeholder="筛选岗位"
|
placeholder="筛选岗位"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
:label="pos.name"
|
:label="pos.name"
|
||||||
:value="pos.id"
|
:value="pos.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.is_active"
|
v-model="filters.is_active"
|
||||||
placeholder="状态"
|
placeholder="状态"
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
<el-button type="primary" @click="handleCreatePath">
|
<el-button type="primary" @click="handleCreatePath">
|
||||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||||
新建路径
|
新建路径
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 路径列表 -->
|
<!-- 路径列表 -->
|
||||||
<div class="path-list-card">
|
<div class="path-list-card">
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
multiple
|
multiple
|
||||||
collapse-tags
|
collapse-tags
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
clearable
|
clearable
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleSelectAllPositions"
|
@click="handleSelectAllPositions"
|
||||||
class="select-all-btn"
|
class="select-all-btn"
|
||||||
>
|
>
|
||||||
@@ -228,11 +228,11 @@
|
|||||||
placeholder="阶段名称"
|
placeholder="阶段名称"
|
||||||
size="small"
|
size="small"
|
||||||
style="width: 100px"
|
style="width: 100px"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<span class="stage-order">{{ index + 1 }}</span>
|
<span class="stage-order">{{ index + 1 }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 路径统计 -->
|
<!-- 路径统计 -->
|
||||||
<div class="top-section stats-section">
|
<div class="top-section stats-section">
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span>
|
<span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span>
|
||||||
<span class="stat-label">课程总数</span>
|
<span class="stat-label">课程总数</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">{{ requiredCount }}</span>
|
<span class="stat-value">{{ requiredCount }}</span>
|
||||||
<span class="stat-label">必修课程</span>
|
<span class="stat-label">必修课程</span>
|
||||||
@@ -261,10 +261,10 @@
|
|||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">{{ totalDuration }}</span>
|
<span class="stat-value">{{ totalDuration }}</span>
|
||||||
<span class="stat-label">总学时(h)</span>
|
<span class="stat-label">总学时(h)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 下方:左右分栏 -->
|
<!-- 下方:左右分栏 -->
|
||||||
<div class="editor-bottom">
|
<div class="editor-bottom">
|
||||||
@@ -303,11 +303,11 @@
|
|||||||
<div class="course-list" v-loading="coursesLoading">
|
<div class="course-list" v-loading="coursesLoading">
|
||||||
<div
|
<div
|
||||||
v-for="course in filteredCourses"
|
v-for="course in filteredCourses"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
class="course-item"
|
class="course-item"
|
||||||
:class="{ 'is-added': isNodeAdded(course.id) }"
|
:class="{ 'is-added': isNodeAdded(course.id) }"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="handleDragStart($event, course)"
|
@dragstart="handleDragStart($event, course)"
|
||||||
@click="handleAddCourse(course)"
|
@click="handleAddCourse(course)"
|
||||||
>
|
>
|
||||||
<div class="course-info">
|
<div class="course-info">
|
||||||
@@ -320,21 +320,21 @@
|
|||||||
<el-icon v-else><Plus /></el-icon>
|
<el-icon v-else><Plus /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" />
|
<el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧 2/3:已选课程配置 -->
|
<!-- 右侧 2/3:已选课程配置 -->
|
||||||
<div class="selected-courses-panel">
|
<div class="selected-courses-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span>已选课程配置</span>
|
<span>已选课程配置</span>
|
||||||
<el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} 门</el-tag>
|
<el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} 门</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="selected-content"
|
class="selected-content"
|
||||||
:class="{ 'is-dragging-over': isDraggingOver }"
|
:class="{ 'is-dragging-over': isDraggingOver }"
|
||||||
@dragover.prevent="handleDragOver"
|
@dragover.prevent="handleDragOver"
|
||||||
@dragleave="handleDragLeave"
|
@dragleave="handleDragLeave"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
>
|
>
|
||||||
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
|
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
|
||||||
<div
|
<div
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
<el-tag size="small" type="info">
|
<el-tag size="small" type="info">
|
||||||
{{ getStageNodes(stage.name).length }} 门
|
{{ getStageNodes(stage.name).length }} 门
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="stage-nodes">
|
<div class="stage-nodes">
|
||||||
<div
|
<div
|
||||||
v-for="(node, nodeIndex) in getStageNodes(stage.name)"
|
v-for="(node, nodeIndex) in getStageNodes(stage.name)"
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
>
|
>
|
||||||
<div class="node-drag-handle">
|
<div class="node-drag-handle">
|
||||||
<el-icon><Rank /></el-icon>
|
<el-icon><Rank /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="node-content">
|
<div class="node-content">
|
||||||
<span class="node-title">{{ node.title }}</span>
|
<span class="node-title">{{ node.title }}</span>
|
||||||
<div class="node-meta">
|
<div class="node-meta">
|
||||||
@@ -367,10 +367,10 @@
|
|||||||
style="cursor: pointer"
|
style="cursor: pointer"
|
||||||
>
|
>
|
||||||
{{ node.is_required ? '必修' : '选修' }}
|
{{ node.is_required ? '必修' : '选修' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span>{{ node.estimated_days || 1 }}天</span>
|
<span>{{ node.estimated_days || 1 }}天</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="node-actions">
|
<div class="node-actions">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="node.stage_name"
|
v-model="node.stage_name"
|
||||||
@@ -393,16 +393,16 @@
|
|||||||
>
|
>
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="getStageNodes(stage.name).length === 0"
|
v-if="getStageNodes(stage.name).length === 0"
|
||||||
class="stage-empty"
|
class="stage-empty"
|
||||||
>
|
>
|
||||||
拖拽或点击课程添加到此阶段
|
拖拽或点击课程添加到此阶段
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else description="请从左侧添加课程" :image-size="60">
|
<el-empty v-else description="请从左侧添加课程" :image-size="60">
|
||||||
<template #description>
|
<template #description>
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -768,10 +768,10 @@ const handleDeletePath = async (row: GrowthPathListItem) => {
|
|||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定要删除路径"${row.name}"吗?此操作不可恢复。`,
|
`确定要删除路径"${row.name}"吗?此操作不可恢复。`,
|
||||||
'删除确认',
|
'删除确认',
|
||||||
{
|
{
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1040,9 +1040,9 @@ onMounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.top-section {
|
.top-section {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -1059,7 +1059,7 @@ onMounted(() => {
|
|||||||
flex: 2;
|
flex: 2;
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
@@ -1113,9 +1113,9 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1134,7 +1134,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.stage-order {
|
.stage-order {
|
||||||
color: #667eea;
|
color: #667eea;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1150,7 +1150,7 @@ onMounted(() => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.stat-box {
|
.stat-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
@@ -1188,8 +1188,8 @@ onMounted(() => {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1254,7 +1254,7 @@ onMounted(() => {
|
|||||||
.course-name {
|
.course-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1266,34 +1266,34 @@ onMounted(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 右侧已选课程 2/3
|
// 右侧已选课程 2/3
|
||||||
.selected-courses-panel {
|
.selected-courses-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-content {
|
.selected-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 2px dashed transparent;
|
border: 2px dashed transparent;
|
||||||
@@ -1341,7 +1341,7 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid #ebeef5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1359,28 +1359,28 @@ onMounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
.node-title {
|
.node-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: #333;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-meta {
|
.node-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-actions {
|
.node-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1390,12 +1390,12 @@ onMounted(() => {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -1424,10 +1424,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-bottom {
|
.editor-bottom {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.course-library-panel {
|
.course-library-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user