- 新增数据库表: growth_path_nodes, user_growth_path_progress, user_node_completions - 新增 Model: GrowthPathNode, UserGrowthPathProgress, UserNodeCompletion - 新增 Service: GrowthPathService(管理端CRUD、学员端进度追踪) - 新增 API: 学员端获取成长路径、管理端CRUD - 前端学员端从API动态加载成长路径数据 - 更新管理端API接口定义
This commit is contained in:
@@ -125,5 +125,8 @@ api_router.include_router(speech_router, prefix="/speech", tags=["speech"])
|
|||||||
# recommendation_router 智能推荐路由
|
# recommendation_router 智能推荐路由
|
||||||
from .endpoints.recommendation import router as recommendation_router
|
from .endpoints.recommendation import router as recommendation_router
|
||||||
api_router.include_router(recommendation_router, prefix="/recommendations", tags=["recommendations"])
|
api_router.include_router(recommendation_router, prefix="/recommendations", tags=["recommendations"])
|
||||||
|
# growth_path_router 成长路径路由
|
||||||
|
from .endpoints.growth_path import router as growth_path_router
|
||||||
|
api_router.include_router(growth_path_router, tags=["growth-path"])
|
||||||
|
|
||||||
__all__ = ["api_router"]
|
__all__ = ["api_router"]
|
||||||
|
|||||||
234
backend/app/api/v1/endpoints/growth_path.py
Normal file
234
backend/app/api/v1/endpoints/growth_path.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
成长路径 API 端点
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.growth_path_service import growth_path_service
|
||||||
|
from app.schemas.growth_path import (
|
||||||
|
GrowthPathCreate,
|
||||||
|
GrowthPathUpdate,
|
||||||
|
GrowthPathResponse,
|
||||||
|
GrowthPathListResponse,
|
||||||
|
TraineeGrowthPathResponse,
|
||||||
|
UserGrowthPathProgressResponse,
|
||||||
|
StartGrowthPathRequest,
|
||||||
|
CompleteNodeRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 学员端 API
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
@router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse])
|
||||||
|
async def get_trainee_growth_path(
|
||||||
|
position_id: Optional[int] = Query(None, description="岗位ID,不传则自动匹配"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取学员的成长路径(含进度)
|
||||||
|
|
||||||
|
返回数据包含:
|
||||||
|
- 成长路径基本信息
|
||||||
|
- 各阶段及节点信息
|
||||||
|
- 每个节点的学习状态(locked/unlocked/in_progress/completed)
|
||||||
|
- 每个节点的课程学习进度
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await growth_path_service.get_trainee_growth_path(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
position_id=position_id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取成长路径失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trainee/growth-path/start")
|
||||||
|
async def start_growth_path(
|
||||||
|
request: StartGrowthPathRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
开始学习成长路径
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
progress = await growth_path_service.start_growth_path(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
growth_path_id=request.growth_path_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "已开始学习成长路径",
|
||||||
|
"progress_id": progress.id,
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"开始成长路径失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trainee/growth-path/node/complete")
|
||||||
|
async def complete_growth_path_node(
|
||||||
|
request: CompleteNodeRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
完成成长路径节点
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await growth_path_service.complete_node(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
node_id=request.node_id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"完成节点失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 管理端 API
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
@router.get("/manager/growth-paths")
|
||||||
|
async def list_growth_paths(
|
||||||
|
position_id: Optional[int] = Query(None, description="岗位ID筛选"),
|
||||||
|
is_active: Optional[bool] = Query(None, description="是否启用"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取成长路径列表(管理端)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await growth_path_service.list_growth_paths(
|
||||||
|
db=db,
|
||||||
|
position_id=position_id,
|
||||||
|
is_active=is_active,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取成长路径列表失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manager/growth-paths")
|
||||||
|
async def create_growth_path(
|
||||||
|
data: GrowthPathCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建成长路径(管理端)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
growth_path = await growth_path_service.create_growth_path(
|
||||||
|
db=db,
|
||||||
|
data=data,
|
||||||
|
created_by=current_user.id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "创建成功",
|
||||||
|
"id": growth_path.id,
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建成长路径失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manager/growth-paths/{path_id}")
|
||||||
|
async def get_growth_path(
|
||||||
|
path_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取成长路径详情(管理端)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await growth_path_service.get_growth_path(db=db, path_id=path_id)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="成长路径不存在")
|
||||||
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取成长路径详情失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/manager/growth-paths/{path_id}")
|
||||||
|
async def update_growth_path(
|
||||||
|
path_id: int,
|
||||||
|
data: GrowthPathUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新成长路径(管理端)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await growth_path_service.update_growth_path(
|
||||||
|
db=db,
|
||||||
|
path_id=path_id,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "更新成功",
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新成长路径失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/manager/growth-paths/{path_id}")
|
||||||
|
async def delete_growth_path(
|
||||||
|
path_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除成长路径(管理端)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await growth_path_service.delete_growth_path(db=db, path_id=path_id)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "删除成功",
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除成长路径失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
118
backend/app/migrations/add_growth_path_tables.sql
Normal file
118
backend/app/migrations/add_growth_path_tables.sql
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
-- 成长路径功能数据库迁移脚本
|
||||||
|
-- 创建时间: 2026-01-30
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 1. 修改 growth_paths 表,添加岗位关联
|
||||||
|
-- =====================================================
|
||||||
|
ALTER TABLE growth_paths
|
||||||
|
ADD COLUMN IF NOT EXISTS position_id INT NULL COMMENT '关联岗位ID' AFTER target_role,
|
||||||
|
ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses,
|
||||||
|
ADD INDEX idx_position_id (position_id);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 2. 创建成长路径节点表
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS growth_path_nodes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||||
|
course_id INT NOT NULL COMMENT '课程ID',
|
||||||
|
stage_name VARCHAR(100) NULL COMMENT '所属阶段名称',
|
||||||
|
title VARCHAR(200) NOT NULL COMMENT '节点标题',
|
||||||
|
description TEXT NULL COMMENT '节点描述',
|
||||||
|
order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序',
|
||||||
|
is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修',
|
||||||
|
prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]',
|
||||||
|
estimated_days INT DEFAULT 7 COMMENT '预计学习天数',
|
||||||
|
|
||||||
|
-- 软删除
|
||||||
|
is_deleted BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
INDEX idx_growth_path_id (growth_path_id),
|
||||||
|
INDEX idx_course_id (course_id),
|
||||||
|
INDEX idx_stage_name (stage_name),
|
||||||
|
INDEX idx_order_num (order_num),
|
||||||
|
INDEX idx_is_deleted (is_deleted),
|
||||||
|
|
||||||
|
-- 外键
|
||||||
|
CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id)
|
||||||
|
REFERENCES growth_paths(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_gpn_course FOREIGN KEY (course_id)
|
||||||
|
REFERENCES courses(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='成长路径节点表';
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 3. 创建用户成长路径进度表
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS user_growth_path_progress (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL COMMENT '用户ID',
|
||||||
|
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||||
|
current_node_id INT NULL COMMENT '当前学习节点ID',
|
||||||
|
completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]',
|
||||||
|
total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比',
|
||||||
|
status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed',
|
||||||
|
|
||||||
|
-- 时间记录
|
||||||
|
started_at DATETIME NULL COMMENT '开始时间',
|
||||||
|
completed_at DATETIME NULL COMMENT '完成时间',
|
||||||
|
last_activity_at DATETIME NULL COMMENT '最后活动时间',
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_growth_path_id (growth_path_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
UNIQUE KEY uk_user_growth_path (user_id, growth_path_id),
|
||||||
|
|
||||||
|
-- 外键
|
||||||
|
CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id)
|
||||||
|
REFERENCES growth_paths(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='用户成长路径进度表';
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况)
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS user_node_completions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL COMMENT '用户ID',
|
||||||
|
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||||
|
node_id INT NOT NULL COMMENT '节点ID',
|
||||||
|
course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度',
|
||||||
|
status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed',
|
||||||
|
|
||||||
|
-- 时间记录
|
||||||
|
unlocked_at DATETIME NULL COMMENT '解锁时间',
|
||||||
|
started_at DATETIME NULL COMMENT '开始学习时间',
|
||||||
|
completed_at DATETIME NULL COMMENT '完成时间',
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_growth_path_id (growth_path_id),
|
||||||
|
INDEX idx_node_id (node_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
UNIQUE KEY uk_user_node (user_id, node_id),
|
||||||
|
|
||||||
|
-- 外键
|
||||||
|
CONSTRAINT fk_unc_user FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_unc_node FOREIGN KEY (node_id)
|
||||||
|
REFERENCES growth_path_nodes(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='用户节点完成记录表';
|
||||||
@@ -37,6 +37,13 @@ from app.models.user_course_progress import (
|
|||||||
UserMaterialProgress,
|
UserMaterialProgress,
|
||||||
ProgressStatus,
|
ProgressStatus,
|
||||||
)
|
)
|
||||||
|
from app.models.growth_path import (
|
||||||
|
GrowthPathNode,
|
||||||
|
UserGrowthPathProgress,
|
||||||
|
UserNodeCompletion,
|
||||||
|
GrowthPathStatus,
|
||||||
|
NodeStatus,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -80,4 +87,9 @@ __all__ = [
|
|||||||
"UserCourseProgress",
|
"UserCourseProgress",
|
||||||
"UserMaterialProgress",
|
"UserMaterialProgress",
|
||||||
"ProgressStatus",
|
"ProgressStatus",
|
||||||
|
"GrowthPathNode",
|
||||||
|
"UserGrowthPathProgress",
|
||||||
|
"UserNodeCompletion",
|
||||||
|
"GrowthPathStatus",
|
||||||
|
"NodeStatus",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -224,9 +224,19 @@ class GrowthPath(BaseModel, SoftDeleteMixin):
|
|||||||
String(100), nullable=True, comment="目标角色"
|
String(100), nullable=True, comment="目标角色"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 路径配置
|
# 岗位关联
|
||||||
|
position_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True, comment="关联岗位ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 路径配置(保留用于兼容,新版使用 nodes 关联表)
|
||||||
courses: Mapped[Optional[List[dict]]] = mapped_column(
|
courses: Mapped[Optional[List[dict]]] = mapped_column(
|
||||||
JSON, nullable=True, comment="课程列表[{course_id, order, is_required}]"
|
JSON, nullable=True, comment="课程列表[{course_id, order, is_required}](已废弃,使用nodes)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 阶段配置
|
||||||
|
stages: Mapped[Optional[List[dict]]] = mapped_column(
|
||||||
|
JSON, nullable=True, comment="阶段配置[{name, description, order}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 预计时长
|
# 预计时长
|
||||||
@@ -242,6 +252,11 @@ class GrowthPath(BaseModel, SoftDeleteMixin):
|
|||||||
Integer, default=0, nullable=False, comment="排序顺序"
|
Integer, default=0, nullable=False, comment="排序顺序"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
nodes: Mapped[List["GrowthPathNode"]] = relationship(
|
||||||
|
"GrowthPathNode", back_populates="growth_path", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MaterialKnowledgePoint(BaseModel, SoftDeleteMixin):
|
class MaterialKnowledgePoint(BaseModel, SoftDeleteMixin):
|
||||||
"""
|
"""
|
||||||
|
|||||||
206
backend/app/models/growth_path.py
Normal file
206
backend/app/models/growth_path.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
成长路径相关数据库模型
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
Integer,
|
||||||
|
Boolean,
|
||||||
|
ForeignKey,
|
||||||
|
Enum as SQLEnum,
|
||||||
|
JSON,
|
||||||
|
DateTime,
|
||||||
|
DECIMAL,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import BaseModel, SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathStatus(str, Enum):
|
||||||
|
"""成长路径学习状态"""
|
||||||
|
NOT_STARTED = "not_started" # 未开始
|
||||||
|
IN_PROGRESS = "in_progress" # 进行中
|
||||||
|
COMPLETED = "completed" # 已完成
|
||||||
|
|
||||||
|
|
||||||
|
class NodeStatus(str, Enum):
|
||||||
|
"""节点状态"""
|
||||||
|
LOCKED = "locked" # 锁定(前置未完成)
|
||||||
|
UNLOCKED = "unlocked" # 已解锁(可以开始)
|
||||||
|
IN_PROGRESS = "in_progress" # 学习中
|
||||||
|
COMPLETED = "completed" # 已完成
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathNode(BaseModel, SoftDeleteMixin):
|
||||||
|
"""
|
||||||
|
成长路径节点表
|
||||||
|
每个节点对应一门课程
|
||||||
|
"""
|
||||||
|
__tablename__ = "growth_path_nodes"
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
growth_path_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="成长路径ID"
|
||||||
|
)
|
||||||
|
course_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("courses.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="课程ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 节点信息
|
||||||
|
stage_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100), nullable=True, comment="所属阶段名称"
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(
|
||||||
|
String(200), nullable=False, comment="节点标题"
|
||||||
|
)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text, nullable=True, comment="节点描述"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
order_num: Mapped[int] = mapped_column(
|
||||||
|
Integer, default=0, nullable=False, comment="排序顺序"
|
||||||
|
)
|
||||||
|
is_required: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, nullable=False, comment="是否必修"
|
||||||
|
)
|
||||||
|
prerequisites: Mapped[Optional[List[int]]] = mapped_column(
|
||||||
|
JSON, nullable=True, comment="前置节点IDs"
|
||||||
|
)
|
||||||
|
estimated_days: Mapped[int] = mapped_column(
|
||||||
|
Integer, default=7, nullable=False, comment="预计学习天数"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
growth_path: Mapped["GrowthPath"] = relationship(
|
||||||
|
"GrowthPath", back_populates="nodes"
|
||||||
|
)
|
||||||
|
course: Mapped["Course"] = relationship("Course")
|
||||||
|
user_completions: Mapped[List["UserNodeCompletion"]] = relationship(
|
||||||
|
"UserNodeCompletion", back_populates="node"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserGrowthPathProgress(BaseModel):
|
||||||
|
"""
|
||||||
|
用户成长路径进度表
|
||||||
|
记录用户在某条成长路径上的整体进度
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_growth_path_progress"
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="用户ID"
|
||||||
|
)
|
||||||
|
growth_path_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="成长路径ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 进度信息
|
||||||
|
current_node_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True, comment="当前学习节点ID"
|
||||||
|
)
|
||||||
|
completed_node_ids: Mapped[Optional[List[int]]] = mapped_column(
|
||||||
|
JSON, nullable=True, comment="已完成节点IDs"
|
||||||
|
)
|
||||||
|
total_progress: Mapped[Decimal] = mapped_column(
|
||||||
|
DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default=GrowthPathStatus.NOT_STARTED.value,
|
||||||
|
nullable=False,
|
||||||
|
comment="状态"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 时间记录
|
||||||
|
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, comment="开始时间"
|
||||||
|
)
|
||||||
|
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||||
|
)
|
||||||
|
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, comment="最后活动时间"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
|
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||||
|
|
||||||
|
|
||||||
|
class UserNodeCompletion(BaseModel):
|
||||||
|
"""
|
||||||
|
用户节点完成记录表
|
||||||
|
详细记录用户在每个节点上的学习状态
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_node_completions"
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="用户ID"
|
||||||
|
)
|
||||||
|
growth_path_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="成长路径ID"
|
||||||
|
)
|
||||||
|
node_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("growth_path_nodes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="节点ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 进度信息
|
||||||
|
course_progress: Mapped[Decimal] = mapped_column(
|
||||||
|
DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度"
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default=NodeStatus.LOCKED.value,
|
||||||
|
nullable=False,
|
||||||
|
comment="状态"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 时间记录
|
||||||
|
unlocked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, comment="解锁时间"
|
||||||
|
)
|
||||||
|
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, comment="开始学习时间"
|
||||||
|
)
|
||||||
|
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
|
node: Mapped["GrowthPathNode"] = relationship(
|
||||||
|
"GrowthPathNode", back_populates="user_completions"
|
||||||
|
)
|
||||||
|
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||||
224
backend/app/schemas/growth_path.py
Normal file
224
backend/app/schemas/growth_path.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
成长路径相关 Schema
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 基础数据结构
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
class StageConfig(BaseModel):
|
||||||
|
"""阶段配置"""
|
||||||
|
name: str = Field(..., description="阶段名称")
|
||||||
|
description: Optional[str] = Field(None, description="阶段描述")
|
||||||
|
order: int = Field(0, description="排序")
|
||||||
|
|
||||||
|
|
||||||
|
class NodeBase(BaseModel):
|
||||||
|
"""节点基础信息"""
|
||||||
|
course_id: int = Field(..., description="课程ID")
|
||||||
|
stage_name: Optional[str] = Field(None, description="所属阶段名称")
|
||||||
|
title: str = Field(..., description="节点标题")
|
||||||
|
description: Optional[str] = Field(None, description="节点描述")
|
||||||
|
order_num: int = Field(0, description="排序顺序")
|
||||||
|
is_required: bool = Field(True, description="是否必修")
|
||||||
|
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
|
||||||
|
estimated_days: int = Field(7, description="预计学习天数")
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 管理端 - 创建/更新
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
class GrowthPathNodeCreate(NodeBase):
|
||||||
|
"""创建节点"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathNodeUpdate(BaseModel):
|
||||||
|
"""更新节点"""
|
||||||
|
course_id: Optional[int] = None
|
||||||
|
stage_name: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
order_num: Optional[int] = None
|
||||||
|
is_required: Optional[bool] = None
|
||||||
|
prerequisites: Optional[List[int]] = None
|
||||||
|
estimated_days: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathCreate(BaseModel):
|
||||||
|
"""创建成长路径"""
|
||||||
|
name: str = Field(..., description="路径名称")
|
||||||
|
description: Optional[str] = Field(None, description="路径描述")
|
||||||
|
target_role: Optional[str] = Field(None, description="目标角色")
|
||||||
|
position_id: Optional[int] = Field(None, description="关联岗位ID")
|
||||||
|
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
|
||||||
|
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
|
||||||
|
is_active: bool = Field(True, description="是否启用")
|
||||||
|
sort_order: int = Field(0, description="排序")
|
||||||
|
nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表")
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathUpdate(BaseModel):
|
||||||
|
"""更新成长路径"""
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
target_role: Optional[str] = None
|
||||||
|
position_id: Optional[int] = None
|
||||||
|
stages: Optional[List[StageConfig]] = None
|
||||||
|
estimated_duration_days: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 管理端 - 响应
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
class GrowthPathNodeResponse(NodeBase):
|
||||||
|
"""节点响应"""
|
||||||
|
id: int
|
||||||
|
growth_path_id: int
|
||||||
|
course_name: Optional[str] = None # 课程名称(关联查询)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathResponse(BaseModel):
|
||||||
|
"""成长路径响应(管理端)"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
target_role: Optional[str] = None
|
||||||
|
position_id: Optional[int] = None
|
||||||
|
position_name: Optional[str] = None # 岗位名称(关联查询)
|
||||||
|
stages: Optional[List[StageConfig]] = None
|
||||||
|
estimated_duration_days: Optional[int] = None
|
||||||
|
is_active: bool
|
||||||
|
sort_order: int
|
||||||
|
nodes: List[GrowthPathNodeResponse] = []
|
||||||
|
node_count: int = 0 # 节点数量
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathListResponse(BaseModel):
|
||||||
|
"""成长路径列表响应"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
position_id: Optional[int] = None
|
||||||
|
position_name: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
node_count: int = 0
|
||||||
|
estimated_duration_days: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 学员端 - 响应
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
class TraineeNodeResponse(BaseModel):
|
||||||
|
"""学员端节点响应(含进度状态)"""
|
||||||
|
id: int
|
||||||
|
course_id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
stage_name: Optional[str] = None
|
||||||
|
is_required: bool
|
||||||
|
estimated_days: int
|
||||||
|
order_num: int
|
||||||
|
|
||||||
|
# 学员特有
|
||||||
|
status: str = Field(..., description="状态: locked/unlocked/in_progress/completed")
|
||||||
|
progress: float = Field(0, description="课程学习进度 0-100")
|
||||||
|
|
||||||
|
# 课程信息
|
||||||
|
course_name: Optional[str] = None
|
||||||
|
course_cover: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TraineeStageResponse(BaseModel):
|
||||||
|
"""学员端阶段响应"""
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
completed: int = Field(0, description="已完成节点数")
|
||||||
|
total: int = Field(0, description="总节点数")
|
||||||
|
nodes: List[TraineeNodeResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TraineeGrowthPathResponse(BaseModel):
|
||||||
|
"""学员端成长路径响应"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
position_id: Optional[int] = None
|
||||||
|
position_name: Optional[str] = None
|
||||||
|
|
||||||
|
# 进度信息
|
||||||
|
total_progress: float = Field(0, description="总进度百分比")
|
||||||
|
completed_nodes: int = Field(0, description="已完成节点数")
|
||||||
|
total_nodes: int = Field(0, description="总节点数")
|
||||||
|
status: str = Field("not_started", description="状态: not_started/in_progress/completed")
|
||||||
|
|
||||||
|
# 时间信息
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
estimated_completion_days: Optional[int] = None
|
||||||
|
|
||||||
|
# 阶段和节点
|
||||||
|
stages: List[TraineeStageResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 用户进度
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
class UserGrowthPathProgressResponse(BaseModel):
|
||||||
|
"""用户成长路径进度响应"""
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
growth_path_id: int
|
||||||
|
growth_path_name: str
|
||||||
|
current_node_id: Optional[int] = None
|
||||||
|
current_node_title: Optional[str] = None
|
||||||
|
completed_node_ids: List[int] = []
|
||||||
|
total_progress: float
|
||||||
|
status: str
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
last_activity_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StartGrowthPathRequest(BaseModel):
|
||||||
|
"""开始学习成长路径请求"""
|
||||||
|
growth_path_id: int = Field(..., description="成长路径ID")
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteNodeRequest(BaseModel):
|
||||||
|
"""完成节点请求"""
|
||||||
|
node_id: int = Field(..., description="节点ID")
|
||||||
743
backend/app/services/growth_path_service.py
Normal file
743
backend/app/services/growth_path_service.py
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
"""
|
||||||
|
成长路径服务
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, and_, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.course import GrowthPath, Course
|
||||||
|
from app.models.growth_path import (
|
||||||
|
GrowthPathNode,
|
||||||
|
UserGrowthPathProgress,
|
||||||
|
UserNodeCompletion,
|
||||||
|
GrowthPathStatus,
|
||||||
|
NodeStatus,
|
||||||
|
)
|
||||||
|
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.schemas.growth_path import (
|
||||||
|
GrowthPathCreate,
|
||||||
|
GrowthPathUpdate,
|
||||||
|
GrowthPathNodeCreate,
|
||||||
|
TraineeGrowthPathResponse,
|
||||||
|
TraineeStageResponse,
|
||||||
|
TraineeNodeResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthPathService:
|
||||||
|
"""成长路径服务"""
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 管理端 - CRUD
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
async def create_growth_path(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
data: GrowthPathCreate,
|
||||||
|
created_by: int
|
||||||
|
) -> GrowthPath:
|
||||||
|
"""创建成长路径"""
|
||||||
|
# 检查名称是否重复
|
||||||
|
existing = await db.execute(
|
||||||
|
select(GrowthPath).where(
|
||||||
|
and_(
|
||||||
|
GrowthPath.name == data.name,
|
||||||
|
GrowthPath.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise ValueError(f"成长路径名称 '{data.name}' 已存在")
|
||||||
|
|
||||||
|
# 创建成长路径
|
||||||
|
growth_path = GrowthPath(
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
target_role=data.target_role,
|
||||||
|
position_id=data.position_id,
|
||||||
|
stages=[s.model_dump() for s in data.stages] if data.stages else None,
|
||||||
|
estimated_duration_days=data.estimated_duration_days,
|
||||||
|
is_active=data.is_active,
|
||||||
|
sort_order=data.sort_order,
|
||||||
|
)
|
||||||
|
db.add(growth_path)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 创建节点
|
||||||
|
if data.nodes:
|
||||||
|
for node_data in data.nodes:
|
||||||
|
node = GrowthPathNode(
|
||||||
|
growth_path_id=growth_path.id,
|
||||||
|
course_id=node_data.course_id,
|
||||||
|
stage_name=node_data.stage_name,
|
||||||
|
title=node_data.title,
|
||||||
|
description=node_data.description,
|
||||||
|
order_num=node_data.order_num,
|
||||||
|
is_required=node_data.is_required,
|
||||||
|
prerequisites=node_data.prerequisites,
|
||||||
|
estimated_days=node_data.estimated_days,
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(growth_path)
|
||||||
|
|
||||||
|
logger.info(f"创建成长路径: {growth_path.name}, ID: {growth_path.id}")
|
||||||
|
return growth_path
|
||||||
|
|
||||||
|
async def update_growth_path(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
path_id: int,
|
||||||
|
data: GrowthPathUpdate
|
||||||
|
) -> GrowthPath:
|
||||||
|
"""更新成长路径"""
|
||||||
|
growth_path = await db.get(GrowthPath, path_id)
|
||||||
|
if not growth_path or growth_path.is_deleted:
|
||||||
|
raise ValueError("成长路径不存在")
|
||||||
|
|
||||||
|
# 更新基本信息
|
||||||
|
update_data = data.model_dump(exclude_unset=True, exclude={'nodes'})
|
||||||
|
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']]
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(growth_path, key, value)
|
||||||
|
|
||||||
|
# 如果提供了节点,整体替换
|
||||||
|
if data.nodes is not None:
|
||||||
|
# 删除旧节点
|
||||||
|
await db.execute(
|
||||||
|
delete(GrowthPathNode).where(GrowthPathNode.growth_path_id == path_id)
|
||||||
|
)
|
||||||
|
# 创建新节点
|
||||||
|
for node_data in data.nodes:
|
||||||
|
node = GrowthPathNode(
|
||||||
|
growth_path_id=path_id,
|
||||||
|
course_id=node_data.course_id,
|
||||||
|
stage_name=node_data.stage_name,
|
||||||
|
title=node_data.title,
|
||||||
|
description=node_data.description,
|
||||||
|
order_num=node_data.order_num,
|
||||||
|
is_required=node_data.is_required,
|
||||||
|
prerequisites=node_data.prerequisites,
|
||||||
|
estimated_days=node_data.estimated_days,
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(growth_path)
|
||||||
|
|
||||||
|
logger.info(f"更新成长路径: {growth_path.name}, ID: {path_id}")
|
||||||
|
return growth_path
|
||||||
|
|
||||||
|
async def delete_growth_path(self, db: AsyncSession, path_id: int) -> bool:
|
||||||
|
"""删除成长路径(软删除)"""
|
||||||
|
growth_path = await db.get(GrowthPath, path_id)
|
||||||
|
if not growth_path or growth_path.is_deleted:
|
||||||
|
raise ValueError("成长路径不存在")
|
||||||
|
|
||||||
|
growth_path.is_deleted = True
|
||||||
|
growth_path.deleted_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"删除成长路径: {growth_path.name}, ID: {path_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_growth_path(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
path_id: int
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""获取成长路径详情"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(GrowthPath)
|
||||||
|
.options(selectinload(GrowthPath.nodes))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
GrowthPath.id == path_id,
|
||||||
|
GrowthPath.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
growth_path = result.scalar_one_or_none()
|
||||||
|
if not growth_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 获取岗位名称
|
||||||
|
position_name = None
|
||||||
|
if growth_path.position_id:
|
||||||
|
position = await db.get(Position, growth_path.position_id)
|
||||||
|
if position:
|
||||||
|
position_name = position.name
|
||||||
|
|
||||||
|
# 获取课程名称
|
||||||
|
nodes_data = []
|
||||||
|
for node in sorted(growth_path.nodes, key=lambda x: x.order_num):
|
||||||
|
if node.is_deleted:
|
||||||
|
continue
|
||||||
|
course = await db.get(Course, node.course_id)
|
||||||
|
nodes_data.append({
|
||||||
|
"id": node.id,
|
||||||
|
"growth_path_id": node.growth_path_id,
|
||||||
|
"course_id": node.course_id,
|
||||||
|
"course_name": course.name if course else None,
|
||||||
|
"stage_name": node.stage_name,
|
||||||
|
"title": node.title,
|
||||||
|
"description": node.description,
|
||||||
|
"order_num": node.order_num,
|
||||||
|
"is_required": node.is_required,
|
||||||
|
"prerequisites": node.prerequisites,
|
||||||
|
"estimated_days": node.estimated_days,
|
||||||
|
"created_at": node.created_at,
|
||||||
|
"updated_at": node.updated_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": growth_path.id,
|
||||||
|
"name": growth_path.name,
|
||||||
|
"description": growth_path.description,
|
||||||
|
"target_role": growth_path.target_role,
|
||||||
|
"position_id": growth_path.position_id,
|
||||||
|
"position_name": position_name,
|
||||||
|
"stages": growth_path.stages,
|
||||||
|
"estimated_duration_days": growth_path.estimated_duration_days,
|
||||||
|
"is_active": growth_path.is_active,
|
||||||
|
"sort_order": growth_path.sort_order,
|
||||||
|
"nodes": nodes_data,
|
||||||
|
"node_count": len(nodes_data),
|
||||||
|
"created_at": growth_path.created_at,
|
||||||
|
"updated_at": growth_path.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_growth_paths(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
position_id: Optional[int] = None,
|
||||||
|
is_active: Optional[bool] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""获取成长路径列表"""
|
||||||
|
query = select(GrowthPath).where(GrowthPath.is_deleted == False)
|
||||||
|
|
||||||
|
if position_id is not None:
|
||||||
|
query = query.where(GrowthPath.position_id == position_id)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.where(GrowthPath.is_active == is_active)
|
||||||
|
|
||||||
|
# 计算总数
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count(GrowthPath.id)).where(GrowthPath.is_deleted == False)
|
||||||
|
)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
query = query.order_by(GrowthPath.sort_order, GrowthPath.id.desc())
|
||||||
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
paths = result.scalars().all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for path in paths:
|
||||||
|
# 获取岗位名称
|
||||||
|
position_name = None
|
||||||
|
if path.position_id:
|
||||||
|
position = await db.get(Position, path.position_id)
|
||||||
|
if position:
|
||||||
|
position_name = position.name
|
||||||
|
|
||||||
|
# 获取节点数量
|
||||||
|
node_count_result = await db.execute(
|
||||||
|
select(func.count(GrowthPathNode.id)).where(
|
||||||
|
and_(
|
||||||
|
GrowthPathNode.growth_path_id == path.id,
|
||||||
|
GrowthPathNode.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
node_count = node_count_result.scalar() or 0
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": path.id,
|
||||||
|
"name": path.name,
|
||||||
|
"description": path.description,
|
||||||
|
"position_id": path.position_id,
|
||||||
|
"position_name": position_name,
|
||||||
|
"is_active": path.is_active,
|
||||||
|
"node_count": node_count,
|
||||||
|
"estimated_duration_days": path.estimated_duration_days,
|
||||||
|
"created_at": path.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 学员端 - 获取成长路径
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
async def get_trainee_growth_path(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
position_id: Optional[int] = None
|
||||||
|
) -> Optional[TraineeGrowthPathResponse]:
|
||||||
|
"""
|
||||||
|
获取学员的成长路径(含进度)
|
||||||
|
如果指定了岗位ID,返回该岗位的成长路径
|
||||||
|
否则根据用户岗位自动匹配
|
||||||
|
"""
|
||||||
|
# 查找成长路径
|
||||||
|
query = select(GrowthPath).where(
|
||||||
|
and_(
|
||||||
|
GrowthPath.is_deleted == False,
|
||||||
|
GrowthPath.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if position_id:
|
||||||
|
query = query.where(GrowthPath.position_id == position_id)
|
||||||
|
|
||||||
|
query = query.order_by(GrowthPath.sort_order).limit(1)
|
||||||
|
result = await db.execute(query)
|
||||||
|
growth_path = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not growth_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 获取岗位名称
|
||||||
|
position_name = None
|
||||||
|
if growth_path.position_id:
|
||||||
|
position = await db.get(Position, growth_path.position_id)
|
||||||
|
if position:
|
||||||
|
position_name = position.name
|
||||||
|
|
||||||
|
# 获取所有节点
|
||||||
|
nodes_result = await db.execute(
|
||||||
|
select(GrowthPathNode).where(
|
||||||
|
and_(
|
||||||
|
GrowthPathNode.growth_path_id == growth_path.id,
|
||||||
|
GrowthPathNode.is_deleted == False
|
||||||
|
)
|
||||||
|
).order_by(GrowthPathNode.order_num)
|
||||||
|
)
|
||||||
|
nodes = nodes_result.scalars().all()
|
||||||
|
|
||||||
|
# 获取用户进度
|
||||||
|
progress_result = await db.execute(
|
||||||
|
select(UserGrowthPathProgress).where(
|
||||||
|
and_(
|
||||||
|
UserGrowthPathProgress.user_id == user_id,
|
||||||
|
UserGrowthPathProgress.growth_path_id == growth_path.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_progress = progress_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 获取用户节点完成情况
|
||||||
|
completions_result = await db.execute(
|
||||||
|
select(UserNodeCompletion).where(
|
||||||
|
and_(
|
||||||
|
UserNodeCompletion.user_id == user_id,
|
||||||
|
UserNodeCompletion.growth_path_id == growth_path.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completions = {c.node_id: c for c in completions_result.scalars().all()}
|
||||||
|
|
||||||
|
# 获取用户课程学习进度
|
||||||
|
course_ids = [n.course_id for n in nodes]
|
||||||
|
course_progress_result = await db.execute(
|
||||||
|
select(UserCourseProgress).where(
|
||||||
|
and_(
|
||||||
|
UserCourseProgress.user_id == user_id,
|
||||||
|
UserCourseProgress.course_id.in_(course_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
course_progress_map = {cp.course_id: cp for cp in course_progress_result.scalars().all()}
|
||||||
|
|
||||||
|
# 构建节点响应
|
||||||
|
completed_node_ids = set(user_progress.completed_node_ids or []) if user_progress else set()
|
||||||
|
nodes_by_stage: Dict[str, List[TraineeNodeResponse]] = {}
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
# 获取课程信息
|
||||||
|
course = await db.get(Course, node.course_id)
|
||||||
|
|
||||||
|
# 计算节点状态
|
||||||
|
node_status = self._calculate_node_status(
|
||||||
|
node=node,
|
||||||
|
completed_node_ids=completed_node_ids,
|
||||||
|
completions=completions,
|
||||||
|
course_progress_map=course_progress_map
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取课程进度
|
||||||
|
course_prog = course_progress_map.get(node.course_id)
|
||||||
|
progress = float(course_prog.progress) if course_prog else 0
|
||||||
|
|
||||||
|
node_response = TraineeNodeResponse(
|
||||||
|
id=node.id,
|
||||||
|
course_id=node.course_id,
|
||||||
|
title=node.title,
|
||||||
|
description=node.description,
|
||||||
|
stage_name=node.stage_name,
|
||||||
|
is_required=node.is_required,
|
||||||
|
estimated_days=node.estimated_days,
|
||||||
|
order_num=node.order_num,
|
||||||
|
status=node_status,
|
||||||
|
progress=progress,
|
||||||
|
course_name=course.name if course else None,
|
||||||
|
course_cover=course.cover_image if course else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
stage_name = node.stage_name or "默认阶段"
|
||||||
|
if stage_name not in nodes_by_stage:
|
||||||
|
nodes_by_stage[stage_name] = []
|
||||||
|
nodes_by_stage[stage_name].append(node_response)
|
||||||
|
|
||||||
|
# 构建阶段响应
|
||||||
|
stages = []
|
||||||
|
stage_configs = growth_path.stages or []
|
||||||
|
stage_order = {s.get('name', ''): s.get('order', i) for i, s in enumerate(stage_configs)}
|
||||||
|
|
||||||
|
for stage_name, stage_nodes in sorted(nodes_by_stage.items(), key=lambda x: stage_order.get(x[0], 999)):
|
||||||
|
completed_in_stage = sum(1 for n in stage_nodes if n.status == NodeStatus.COMPLETED.value)
|
||||||
|
stage_desc = next((s.get('description') for s in stage_configs if s.get('name') == stage_name), None)
|
||||||
|
|
||||||
|
stages.append(TraineeStageResponse(
|
||||||
|
name=stage_name,
|
||||||
|
description=stage_desc,
|
||||||
|
completed=completed_in_stage,
|
||||||
|
total=len(stage_nodes),
|
||||||
|
nodes=stage_nodes,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 计算总体进度
|
||||||
|
total_nodes = len(nodes)
|
||||||
|
completed_count = len(completed_node_ids)
|
||||||
|
total_progress = (completed_count / total_nodes * 100) if total_nodes > 0 else 0
|
||||||
|
|
||||||
|
return TraineeGrowthPathResponse(
|
||||||
|
id=growth_path.id,
|
||||||
|
name=growth_path.name,
|
||||||
|
description=growth_path.description,
|
||||||
|
position_id=growth_path.position_id,
|
||||||
|
position_name=position_name,
|
||||||
|
total_progress=round(total_progress, 1),
|
||||||
|
completed_nodes=completed_count,
|
||||||
|
total_nodes=total_nodes,
|
||||||
|
status=user_progress.status if user_progress else GrowthPathStatus.NOT_STARTED.value,
|
||||||
|
started_at=user_progress.started_at if user_progress else None,
|
||||||
|
estimated_completion_days=growth_path.estimated_duration_days,
|
||||||
|
stages=stages,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_node_status(
|
||||||
|
self,
|
||||||
|
node: GrowthPathNode,
|
||||||
|
completed_node_ids: set,
|
||||||
|
completions: Dict[int, UserNodeCompletion],
|
||||||
|
course_progress_map: Dict[int, UserCourseProgress]
|
||||||
|
) -> str:
|
||||||
|
"""计算节点状态"""
|
||||||
|
# 已完成
|
||||||
|
if node.id in completed_node_ids:
|
||||||
|
return NodeStatus.COMPLETED.value
|
||||||
|
|
||||||
|
# 检查前置节点
|
||||||
|
prerequisites = node.prerequisites or []
|
||||||
|
if prerequisites:
|
||||||
|
for prereq_id in prerequisites:
|
||||||
|
if prereq_id not in completed_node_ids:
|
||||||
|
return NodeStatus.LOCKED.value
|
||||||
|
|
||||||
|
# 检查用户节点记录
|
||||||
|
completion = completions.get(node.id)
|
||||||
|
if completion:
|
||||||
|
if completion.status == NodeStatus.IN_PROGRESS.value:
|
||||||
|
return NodeStatus.IN_PROGRESS.value
|
||||||
|
if completion.status == NodeStatus.COMPLETED.value:
|
||||||
|
return NodeStatus.COMPLETED.value
|
||||||
|
|
||||||
|
# 检查课程进度
|
||||||
|
course_progress = course_progress_map.get(node.course_id)
|
||||||
|
if course_progress:
|
||||||
|
if course_progress.status == ProgressStatus.COMPLETED.value:
|
||||||
|
return NodeStatus.COMPLETED.value
|
||||||
|
if course_progress.progress > 0:
|
||||||
|
return NodeStatus.IN_PROGRESS.value
|
||||||
|
|
||||||
|
# 前置已完成,当前未开始
|
||||||
|
return NodeStatus.UNLOCKED.value
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 学员端 - 开始/完成
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
async def start_growth_path(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
growth_path_id: int
|
||||||
|
) -> UserGrowthPathProgress:
|
||||||
|
"""开始学习成长路径"""
|
||||||
|
# 检查成长路径是否存在
|
||||||
|
growth_path = await db.get(GrowthPath, growth_path_id)
|
||||||
|
if not growth_path or growth_path.is_deleted or not growth_path.is_active:
|
||||||
|
raise ValueError("成长路径不存在或未启用")
|
||||||
|
|
||||||
|
# 检查是否已开始
|
||||||
|
existing = await db.execute(
|
||||||
|
select(UserGrowthPathProgress).where(
|
||||||
|
and_(
|
||||||
|
UserGrowthPathProgress.user_id == user_id,
|
||||||
|
UserGrowthPathProgress.growth_path_id == growth_path_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise ValueError("已开始学习此成长路径")
|
||||||
|
|
||||||
|
# 创建进度记录
|
||||||
|
progress = UserGrowthPathProgress(
|
||||||
|
user_id=user_id,
|
||||||
|
growth_path_id=growth_path_id,
|
||||||
|
status=GrowthPathStatus.IN_PROGRESS.value,
|
||||||
|
started_at=datetime.now(),
|
||||||
|
last_activity_at=datetime.now(),
|
||||||
|
)
|
||||||
|
db.add(progress)
|
||||||
|
|
||||||
|
# 获取第一个节点(无前置依赖的)
|
||||||
|
first_node_result = await db.execute(
|
||||||
|
select(GrowthPathNode).where(
|
||||||
|
and_(
|
||||||
|
GrowthPathNode.growth_path_id == growth_path_id,
|
||||||
|
GrowthPathNode.is_deleted == False
|
||||||
|
)
|
||||||
|
).order_by(GrowthPathNode.order_num).limit(1)
|
||||||
|
)
|
||||||
|
first_node = first_node_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if first_node:
|
||||||
|
progress.current_node_id = first_node.id
|
||||||
|
# 解锁第一个节点
|
||||||
|
completion = UserNodeCompletion(
|
||||||
|
user_id=user_id,
|
||||||
|
growth_path_id=growth_path_id,
|
||||||
|
node_id=first_node.id,
|
||||||
|
status=NodeStatus.UNLOCKED.value,
|
||||||
|
unlocked_at=datetime.now(),
|
||||||
|
)
|
||||||
|
db.add(completion)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(progress)
|
||||||
|
|
||||||
|
logger.info(f"用户 {user_id} 开始学习成长路径 {growth_path_id}")
|
||||||
|
return progress
|
||||||
|
|
||||||
|
async def complete_node(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
node_id: int
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""完成节点"""
|
||||||
|
# 获取节点
|
||||||
|
node = await db.get(GrowthPathNode, node_id)
|
||||||
|
if not node or node.is_deleted:
|
||||||
|
raise ValueError("节点不存在")
|
||||||
|
|
||||||
|
# 获取用户进度
|
||||||
|
progress_result = await db.execute(
|
||||||
|
select(UserGrowthPathProgress).where(
|
||||||
|
and_(
|
||||||
|
UserGrowthPathProgress.user_id == user_id,
|
||||||
|
UserGrowthPathProgress.growth_path_id == node.growth_path_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
progress = progress_result.scalar_one_or_none()
|
||||||
|
if not progress:
|
||||||
|
raise ValueError("请先开始学习此成长路径")
|
||||||
|
|
||||||
|
# 检查前置节点是否完成
|
||||||
|
completed_node_ids = set(progress.completed_node_ids or [])
|
||||||
|
prerequisites = node.prerequisites or []
|
||||||
|
for prereq_id in prerequisites:
|
||||||
|
if prereq_id not in completed_node_ids:
|
||||||
|
raise ValueError("前置节点未完成")
|
||||||
|
|
||||||
|
# 更新节点完成状态
|
||||||
|
completion_result = await db.execute(
|
||||||
|
select(UserNodeCompletion).where(
|
||||||
|
and_(
|
||||||
|
UserNodeCompletion.user_id == user_id,
|
||||||
|
UserNodeCompletion.node_id == node_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completion = completion_result.scalar_one_or_none()
|
||||||
|
if not completion:
|
||||||
|
completion = UserNodeCompletion(
|
||||||
|
user_id=user_id,
|
||||||
|
growth_path_id=node.growth_path_id,
|
||||||
|
node_id=node_id,
|
||||||
|
)
|
||||||
|
db.add(completion)
|
||||||
|
|
||||||
|
completion.status = NodeStatus.COMPLETED.value
|
||||||
|
completion.course_progress = Decimal("100.00")
|
||||||
|
completion.completed_at = datetime.now()
|
||||||
|
|
||||||
|
# 更新用户进度
|
||||||
|
completed_node_ids.add(node_id)
|
||||||
|
progress.completed_node_ids = list(completed_node_ids)
|
||||||
|
progress.last_activity_at = datetime.now()
|
||||||
|
|
||||||
|
# 计算总进度
|
||||||
|
total_nodes_result = await db.execute(
|
||||||
|
select(func.count(GrowthPathNode.id)).where(
|
||||||
|
and_(
|
||||||
|
GrowthPathNode.growth_path_id == node.growth_path_id,
|
||||||
|
GrowthPathNode.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_nodes = total_nodes_result.scalar() or 0
|
||||||
|
progress.total_progress = Decimal(str(len(completed_node_ids) / total_nodes * 100)) if total_nodes > 0 else Decimal("0")
|
||||||
|
|
||||||
|
# 检查是否全部完成
|
||||||
|
if len(completed_node_ids) >= total_nodes:
|
||||||
|
progress.status = GrowthPathStatus.COMPLETED.value
|
||||||
|
progress.completed_at = datetime.now()
|
||||||
|
|
||||||
|
# 解锁下一个节点
|
||||||
|
next_nodes = await self._get_unlockable_nodes(
|
||||||
|
db, node.growth_path_id, completed_node_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"用户 {user_id} 完成节点 {node_id}")
|
||||||
|
return {
|
||||||
|
"completed": True,
|
||||||
|
"total_progress": float(progress.total_progress),
|
||||||
|
"is_path_completed": progress.status == GrowthPathStatus.COMPLETED.value,
|
||||||
|
"unlocked_nodes": [n.id for n in next_nodes],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_unlockable_nodes(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
growth_path_id: int,
|
||||||
|
completed_node_ids: set
|
||||||
|
) -> List[GrowthPathNode]:
|
||||||
|
"""获取可解锁的节点"""
|
||||||
|
nodes_result = await db.execute(
|
||||||
|
select(GrowthPathNode).where(
|
||||||
|
and_(
|
||||||
|
GrowthPathNode.growth_path_id == growth_path_id,
|
||||||
|
GrowthPathNode.is_deleted == False,
|
||||||
|
GrowthPathNode.id.notin_(completed_node_ids) if completed_node_ids else True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nodes = nodes_result.scalars().all()
|
||||||
|
|
||||||
|
unlockable = []
|
||||||
|
for node in nodes:
|
||||||
|
prerequisites = node.prerequisites or []
|
||||||
|
if all(p in completed_node_ids for p in prerequisites):
|
||||||
|
unlockable.append(node)
|
||||||
|
|
||||||
|
return unlockable
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 同步课程进度到节点
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
async def sync_course_progress(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
course_id: int,
|
||||||
|
progress: float
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
同步课程学习进度到成长路径节点
|
||||||
|
当用户完成课程学习时调用
|
||||||
|
"""
|
||||||
|
# 查找包含该课程的节点
|
||||||
|
nodes_result = await db.execute(
|
||||||
|
select(GrowthPathNode).where(
|
||||||
|
and_(
|
||||||
|
GrowthPathNode.course_id == course_id,
|
||||||
|
GrowthPathNode.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nodes = nodes_result.scalars().all()
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
# 获取用户在该成长路径的进度
|
||||||
|
progress_result = await db.execute(
|
||||||
|
select(UserGrowthPathProgress).where(
|
||||||
|
and_(
|
||||||
|
UserGrowthPathProgress.user_id == user_id,
|
||||||
|
UserGrowthPathProgress.growth_path_id == node.growth_path_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_progress = progress_result.scalar_one_or_none()
|
||||||
|
if not user_progress:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 更新节点完成记录
|
||||||
|
completion_result = await db.execute(
|
||||||
|
select(UserNodeCompletion).where(
|
||||||
|
and_(
|
||||||
|
UserNodeCompletion.user_id == user_id,
|
||||||
|
UserNodeCompletion.node_id == node.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completion = completion_result.scalar_one_or_none()
|
||||||
|
if not completion:
|
||||||
|
completion = UserNodeCompletion(
|
||||||
|
user_id=user_id,
|
||||||
|
growth_path_id=node.growth_path_id,
|
||||||
|
node_id=node.id,
|
||||||
|
status=NodeStatus.IN_PROGRESS.value,
|
||||||
|
started_at=datetime.now(),
|
||||||
|
)
|
||||||
|
db.add(completion)
|
||||||
|
|
||||||
|
completion.course_progress = Decimal(str(progress))
|
||||||
|
|
||||||
|
# 如果进度达到100%,自动完成节点
|
||||||
|
if progress >= 100:
|
||||||
|
await self.complete_node(db, user_id, node.id)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
growth_path_service = GrowthPathService()
|
||||||
@@ -102,28 +102,97 @@ export interface CourseDetailForManager extends CourseInfo {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成长路径配置
|
// 成长路径节点
|
||||||
export interface GrowthPathConfig {
|
export interface GrowthPathNode {
|
||||||
id: number
|
id: number
|
||||||
positionId: number
|
growth_path_id: number
|
||||||
positionName: string
|
course_id: number
|
||||||
|
course_name?: string
|
||||||
|
stage_name?: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
order_num: number
|
||||||
|
is_required: boolean
|
||||||
|
prerequisites?: number[]
|
||||||
|
estimated_days: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段配置
|
||||||
|
export interface StageConfig {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
courses: Array<{
|
|
||||||
courseId: number
|
|
||||||
courseName: string
|
|
||||||
isRequired: boolean
|
|
||||||
prerequisites: number[]
|
|
||||||
order: number
|
order: number
|
||||||
estimatedDays: number
|
}
|
||||||
}>
|
|
||||||
totalCourses: number
|
// 成长路径配置(新版)
|
||||||
requiredCourses: number
|
export interface GrowthPathConfig {
|
||||||
optionalCourses: number
|
id: number
|
||||||
estimatedDays: number
|
name: string
|
||||||
status: 'active' | 'inactive'
|
description?: string
|
||||||
createdAt: string
|
target_role?: string
|
||||||
updatedAt: string
|
position_id?: number
|
||||||
|
position_name?: string
|
||||||
|
stages?: StageConfig[]
|
||||||
|
estimated_duration_days?: number
|
||||||
|
is_active: boolean
|
||||||
|
sort_order: number
|
||||||
|
nodes: GrowthPathNode[]
|
||||||
|
node_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成长路径列表项
|
||||||
|
export interface GrowthPathListItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
position_id?: number
|
||||||
|
position_name?: string
|
||||||
|
is_active: boolean
|
||||||
|
node_count: number
|
||||||
|
estimated_duration_days?: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建节点请求
|
||||||
|
export interface CreateGrowthPathNode {
|
||||||
|
course_id: number
|
||||||
|
stage_name?: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
order_num: number
|
||||||
|
is_required: boolean
|
||||||
|
prerequisites?: number[]
|
||||||
|
estimated_days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建成长路径请求
|
||||||
|
export interface CreateGrowthPathRequest {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
target_role?: string
|
||||||
|
position_id?: number
|
||||||
|
stages?: StageConfig[]
|
||||||
|
estimated_duration_days?: number
|
||||||
|
is_active?: boolean
|
||||||
|
sort_order?: number
|
||||||
|
nodes?: CreateGrowthPathNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成长路径请求
|
||||||
|
export interface UpdateGrowthPathRequest {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
target_role?: string
|
||||||
|
position_id?: number
|
||||||
|
stages?: StageConfig[]
|
||||||
|
estimated_duration_days?: number
|
||||||
|
is_active?: boolean
|
||||||
|
sort_order?: number
|
||||||
|
nodes?: CreateGrowthPathNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI陪练场景(管理视图)
|
// AI陪练场景(管理视图)
|
||||||
@@ -364,49 +433,44 @@ export const analyzeKnowledgePoints = (courseId: number) => {
|
|||||||
*/
|
*/
|
||||||
export const getGrowthPathConfigs = (params: {
|
export const getGrowthPathConfigs = (params: {
|
||||||
page?: number
|
page?: number
|
||||||
size?: number
|
page_size?: number
|
||||||
positionId?: number
|
position_id?: number
|
||||||
status?: string
|
is_active?: boolean
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
return request.get<{
|
return request.get<{
|
||||||
items: GrowthPathConfig[]
|
items: GrowthPathListItem[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
size: number
|
page_size: number
|
||||||
}>('/api/v1/manager/growth-paths', { params })
|
}>('/api/v1/manager/growth-paths', { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成长路径详情
|
||||||
|
*/
|
||||||
|
export const getGrowthPathDetail = (pathId: number) => {
|
||||||
|
return request.get<GrowthPathConfig>(`/api/v1/manager/growth-paths/${pathId}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建成长路径
|
* 创建成长路径
|
||||||
*/
|
*/
|
||||||
export const createGrowthPath = (data: {
|
export const createGrowthPath = (data: CreateGrowthPathRequest) => {
|
||||||
positionId: number
|
return request.post<{ success: boolean; message: string; id: number }>('/api/v1/manager/growth-paths', data)
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
courses: Array<{
|
|
||||||
courseId: number
|
|
||||||
isRequired: boolean
|
|
||||||
prerequisites?: number[]
|
|
||||||
estimatedDays?: number
|
|
||||||
}>
|
|
||||||
}) => {
|
|
||||||
return request.post<GrowthPathConfig>('/api/v1/manager/growth-paths', data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新成长路径
|
* 更新成长路径
|
||||||
*/
|
*/
|
||||||
export const updateGrowthPath = (pathId: number, data: {
|
export const updateGrowthPath = (pathId: number, data: UpdateGrowthPathRequest) => {
|
||||||
name?: string
|
return request.put<{ success: boolean; message: string }>(`/api/v1/manager/growth-paths/${pathId}`, data)
|
||||||
description?: string
|
}
|
||||||
courses?: Array<{
|
|
||||||
courseId: number
|
/**
|
||||||
isRequired: boolean
|
* 删除成长路径
|
||||||
prerequisites?: number[]
|
*/
|
||||||
estimatedDays?: number
|
export const deleteGrowthPath = (pathId: number) => {
|
||||||
}>
|
return request.delete<{ success: boolean; message: string }>(`/api/v1/manager/growth-paths/${pathId}`)
|
||||||
}) => {
|
|
||||||
return request.put<GrowthPathConfig>(`/api/v1/manager/growth-paths/${pathId}`, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,7 +40,48 @@ export interface CourseMaterial {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成长路径节点
|
// 成长路径节点(学员端)
|
||||||
|
export interface TraineeGrowthPathNode {
|
||||||
|
id: number
|
||||||
|
course_id: number
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
stage_name?: string
|
||||||
|
is_required: boolean
|
||||||
|
estimated_days: number
|
||||||
|
order_num: number
|
||||||
|
status: 'locked' | 'unlocked' | 'in_progress' | 'completed'
|
||||||
|
progress: number
|
||||||
|
course_name?: string
|
||||||
|
course_cover?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成长路径阶段
|
||||||
|
export interface TraineeGrowthPathStage {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
completed: number
|
||||||
|
total: number
|
||||||
|
nodes: TraineeGrowthPathNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成长路径(学员端响应)
|
||||||
|
export interface TraineeGrowthPath {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
position_id?: number
|
||||||
|
position_name?: string
|
||||||
|
total_progress: number
|
||||||
|
completed_nodes: number
|
||||||
|
total_nodes: number
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed'
|
||||||
|
started_at?: string
|
||||||
|
estimated_completion_days?: number
|
||||||
|
stages: TraineeGrowthPathStage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧接口
|
||||||
export interface GrowthPathNode {
|
export interface GrowthPathNode {
|
||||||
id: number
|
id: number
|
||||||
courseId: number
|
courseId: number
|
||||||
@@ -54,7 +95,7 @@ export interface GrowthPathNode {
|
|||||||
order: number
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成长路径
|
// 兼容旧接口
|
||||||
export interface GrowthPath {
|
export interface GrowthPath {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -226,10 +267,35 @@ export const markMaterialCompleted = (materialId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取成长路径
|
* 获取成长路径(学员端)
|
||||||
*/
|
*/
|
||||||
export const getGrowthPath = () => {
|
export const getGrowthPath = (positionId?: number) => {
|
||||||
return request.get<GrowthPath>('/api/v1/trainee/growth-path')
|
return request.get<TraineeGrowthPath>('/api/v1/trainee/growth-path', {
|
||||||
|
params: positionId ? { position_id: positionId } : {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始学习成长路径
|
||||||
|
*/
|
||||||
|
export const startGrowthPath = (growthPathId: number) => {
|
||||||
|
return request.post<{ success: boolean; message: string; progress_id: number }>('/api/v1/trainee/growth-path/start', {
|
||||||
|
growth_path_id: growthPathId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成成长路径节点
|
||||||
|
*/
|
||||||
|
export const completeGrowthPathNode = (nodeId: number) => {
|
||||||
|
return request.post<{
|
||||||
|
completed: boolean
|
||||||
|
total_progress: number
|
||||||
|
is_path_completed: boolean
|
||||||
|
unlocked_nodes: number[]
|
||||||
|
}>('/api/v1/trainee/growth-path/node/complete', {
|
||||||
|
node_id: nodeId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -363,7 +363,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
@@ -374,7 +374,8 @@ import {
|
|||||||
Cpu // 机器人图标
|
Cpu // 机器人图标
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { analyzeYanjiBadge, getCourseDetail } from '@/api/trainee'
|
import { analyzeYanjiBadge, getCourseDetail, getGrowthPath, startGrowthPath } from '@/api/trainee'
|
||||||
|
import type { TraineeGrowthPath, TraineeGrowthPathStage } from '@/api/trainee'
|
||||||
import { getCurrentUserProfile } from '@/api/user'
|
import { getCurrentUserProfile } from '@/api/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -451,100 +452,73 @@ const expPercentage = computed(() => {
|
|||||||
return Math.round((userInfo.value.exp / userInfo.value.nextLevelExp) * 100)
|
return Math.round((userInfo.value.exp / userInfo.value.nextLevelExp) * 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 成长路径数据
|
// 成长路径数据(从API加载)
|
||||||
const growthPath = ref([
|
const growthPathData = ref<TraineeGrowthPath | null>(null)
|
||||||
{
|
const growthPathLoading = ref(false)
|
||||||
name: '基础阶段',
|
|
||||||
completed: 3,
|
// 将API数据转换为前端展示格式
|
||||||
total: 3,
|
const growthPath = computed(() => {
|
||||||
nodes: [
|
if (!growthPathData.value || !growthPathData.value.stages) {
|
||||||
{
|
// 返回默认空数据或占位数据
|
||||||
id: 1,
|
return [{
|
||||||
title: '机构文化与服务理念',
|
name: '暂无成长路径',
|
||||||
description: '了解机构的服务理念和专业文化',
|
|
||||||
status: 'completed',
|
|
||||||
progress: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: '美容产品基础知识',
|
|
||||||
description: '学习美容产品的基本知识和使用方法',
|
|
||||||
status: 'completed',
|
|
||||||
progress: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '客户接待与服务标准',
|
|
||||||
description: '掌握专业的客户接待和服务流程',
|
|
||||||
status: 'completed',
|
|
||||||
progress: 100
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '进阶阶段',
|
|
||||||
completed: 2,
|
|
||||||
total: 4,
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '皮肤分析技术',
|
|
||||||
description: '学习专业的皮肤分析方法和技术',
|
|
||||||
status: 'completed',
|
|
||||||
progress: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: '美容仪器操作',
|
|
||||||
description: '掌握各种美容仪器的操作方法',
|
|
||||||
status: 'completed',
|
|
||||||
progress: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: '客户沟通进阶',
|
|
||||||
description: '学习高级的客户沟通和咨询技巧',
|
|
||||||
status: 'current',
|
|
||||||
progress: 65
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
title: '美容方案设计',
|
|
||||||
description: '学习为客户设计个性化的美容方案',
|
|
||||||
status: 'locked',
|
|
||||||
progress: 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '高级阶段',
|
|
||||||
completed: 0,
|
completed: 0,
|
||||||
total: 3,
|
total: 0,
|
||||||
nodes: [
|
nodes: []
|
||||||
{
|
}]
|
||||||
id: 8,
|
}
|
||||||
title: '轻医美项目咨询',
|
|
||||||
description: '掌握轻医美项目的专业咨询技能',
|
return growthPathData.value.stages.map(stage => ({
|
||||||
status: 'locked',
|
name: stage.name,
|
||||||
progress: 0
|
completed: stage.completed,
|
||||||
},
|
total: stage.total,
|
||||||
{
|
nodes: stage.nodes.map(node => ({
|
||||||
id: 9,
|
id: node.id,
|
||||||
title: '团队协作与管理',
|
courseId: node.course_id,
|
||||||
description: '学习团队协作和基础管理技能',
|
title: node.title,
|
||||||
status: 'locked',
|
description: node.description || '',
|
||||||
progress: 0
|
status: node.status, // locked/unlocked/in_progress/completed
|
||||||
},
|
progress: node.progress
|
||||||
{
|
}))
|
||||||
id: 10,
|
}))
|
||||||
title: '高级美容技术',
|
})
|
||||||
description: '掌握高级美容技术和创新方法',
|
|
||||||
status: 'locked',
|
/**
|
||||||
progress: 0
|
* 加载成长路径数据
|
||||||
|
*/
|
||||||
|
const loadGrowthPath = async () => {
|
||||||
|
growthPathLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await getGrowthPath()
|
||||||
|
if (response.code === 200 && response.data) {
|
||||||
|
growthPathData.value = response.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载成长路径失败:', error)
|
||||||
|
// 失败时不显示错误,保持空状态
|
||||||
|
} finally {
|
||||||
|
growthPathLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始学习成长路径
|
||||||
|
*/
|
||||||
|
const handleStartGrowthPath = async () => {
|
||||||
|
if (!growthPathData.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await startGrowthPath(growthPathData.value.id)
|
||||||
|
if (response.code === 200) {
|
||||||
|
ElMessage.success('已开始学习成长路径')
|
||||||
|
// 重新加载数据
|
||||||
|
await loadGrowthPath()
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('开始学习失败:', error)
|
||||||
|
ElMessage.error(error.response?.data?.detail || '开始学习失败')
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化雷达图
|
* 初始化雷达图
|
||||||
@@ -754,12 +728,12 @@ const handleNodeClick = (node: any) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.status === 'completed') {
|
// 跳转到课程详情页
|
||||||
ElMessage.info(`${node.title} 已完成`)
|
if (node.courseId) {
|
||||||
return
|
router.push(`/trainee/course-detail?id=${node.courseId}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.info(`${node.title}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ElMessage.success(`开始学习:${node.title}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -921,6 +895,9 @@ onMounted(async () => {
|
|||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
await fetchUserInfo()
|
await fetchUserInfo()
|
||||||
|
|
||||||
|
// 加载成长路径数据
|
||||||
|
await loadGrowthPath()
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
initRadarChart()
|
initRadarChart()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user