From b4906c543b715ffedaeedf7304496f44b8abf742 Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Fri, 30 Jan 2026 15:37:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=88=90=E9=95=BF?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数据库表: growth_path_nodes, user_growth_path_progress, user_node_completions - 新增 Model: GrowthPathNode, UserGrowthPathProgress, UserNodeCompletion - 新增 Service: GrowthPathService(管理端CRUD、学员端进度追踪) - 新增 API: 学员端获取成长路径、管理端CRUD - 前端学员端从API动态加载成长路径数据 - 更新管理端API接口定义 --- backend/app/api/v1/__init__.py | 3 + backend/app/api/v1/endpoints/growth_path.py | 234 ++++++ .../app/migrations/add_growth_path_tables.sql | 118 +++ backend/app/models/__init__.py | 12 + backend/app/models/course.py | 19 +- backend/app/models/growth_path.py | 206 +++++ backend/app/schemas/growth_path.py | 224 ++++++ backend/app/services/growth_path_service.py | 743 ++++++++++++++++++ frontend/src/api/manager/index.ts | 158 ++-- frontend/src/api/trainee/index.ts | 76 +- frontend/src/views/trainee/growth-path.vue | 177 ++--- 11 files changed, 1816 insertions(+), 154 deletions(-) create mode 100644 backend/app/api/v1/endpoints/growth_path.py create mode 100644 backend/app/migrations/add_growth_path_tables.sql create mode 100644 backend/app/models/growth_path.py create mode 100644 backend/app/schemas/growth_path.py create mode 100644 backend/app/services/growth_path_service.py diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 6d39e38..df5e5f7 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -125,5 +125,8 @@ api_router.include_router(speech_router, prefix="/speech", tags=["speech"]) # recommendation_router 智能推荐路由 from .endpoints.recommendation import router as recommendation_router 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"] diff --git a/backend/app/api/v1/endpoints/growth_path.py b/backend/app/api/v1/endpoints/growth_path.py new file mode 100644 index 0000000..830a8cb --- /dev/null +++ b/backend/app/api/v1/endpoints/growth_path.py @@ -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)) diff --git a/backend/app/migrations/add_growth_path_tables.sql b/backend/app/migrations/add_growth_path_tables.sql new file mode 100644 index 0000000..6324517 --- /dev/null +++ b/backend/app/migrations/add_growth_path_tables.sql @@ -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='用户节点完成记录表'; diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 059dad5..05ef7fc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -37,6 +37,13 @@ from app.models.user_course_progress import ( UserMaterialProgress, ProgressStatus, ) +from app.models.growth_path import ( + GrowthPathNode, + UserGrowthPathProgress, + UserNodeCompletion, + GrowthPathStatus, + NodeStatus, +) __all__ = [ "Base", @@ -80,4 +87,9 @@ __all__ = [ "UserCourseProgress", "UserMaterialProgress", "ProgressStatus", + "GrowthPathNode", + "UserGrowthPathProgress", + "UserNodeCompletion", + "GrowthPathStatus", + "NodeStatus", ] diff --git a/backend/app/models/course.py b/backend/app/models/course.py index 032e62b..ba5fa91 100644 --- a/backend/app/models/course.py +++ b/backend/app/models/course.py @@ -223,10 +223,20 @@ class GrowthPath(BaseModel, SoftDeleteMixin): target_role: Mapped[Optional[str]] = mapped_column( 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( - 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}]" ) # 预计时长 @@ -241,6 +251,11 @@ class GrowthPath(BaseModel, SoftDeleteMixin): sort_order: Mapped[int] = mapped_column( Integer, default=0, nullable=False, comment="排序顺序" ) + + # 关联关系 + nodes: Mapped[List["GrowthPathNode"]] = relationship( + "GrowthPathNode", back_populates="growth_path", cascade="all, delete-orphan" + ) class MaterialKnowledgePoint(BaseModel, SoftDeleteMixin): diff --git a/backend/app/models/growth_path.py b/backend/app/models/growth_path.py new file mode 100644 index 0000000..3b9e2d8 --- /dev/null +++ b/backend/app/models/growth_path.py @@ -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") diff --git a/backend/app/schemas/growth_path.py b/backend/app/schemas/growth_path.py new file mode 100644 index 0000000..4fe085f --- /dev/null +++ b/backend/app/schemas/growth_path.py @@ -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") diff --git a/backend/app/services/growth_path_service.py b/backend/app/services/growth_path_service.py new file mode 100644 index 0000000..6bef73c --- /dev/null +++ b/backend/app/services/growth_path_service.py @@ -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() diff --git a/frontend/src/api/manager/index.ts b/frontend/src/api/manager/index.ts index 176e2f3..710361d 100644 --- a/frontend/src/api/manager/index.ts +++ b/frontend/src/api/manager/index.ts @@ -102,28 +102,97 @@ export interface CourseDetailForManager extends CourseInfo { }> } -// 成长路径配置 -export interface GrowthPathConfig { +// 成长路径节点 +export interface GrowthPathNode { id: number - positionId: number - positionName: string + growth_path_id: number + 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 description?: string - courses: Array<{ - courseId: number - courseName: string - isRequired: boolean - prerequisites: number[] - order: number - estimatedDays: number - }> - totalCourses: number - requiredCourses: number - optionalCourses: number - estimatedDays: number - status: 'active' | 'inactive' - createdAt: string - updatedAt: string + order: number +} + +// 成长路径配置(新版) +export interface GrowthPathConfig { + id: number + name: string + description?: string + target_role?: 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陪练场景(管理视图) @@ -364,49 +433,44 @@ export const analyzeKnowledgePoints = (courseId: number) => { */ export const getGrowthPathConfigs = (params: { page?: number - size?: number - positionId?: number - status?: string + page_size?: number + position_id?: number + is_active?: boolean } = {}) => { return request.get<{ - items: GrowthPathConfig[] + items: GrowthPathListItem[] total: number page: number - size: number + page_size: number }>('/api/v1/manager/growth-paths', { params }) } +/** + * 获取成长路径详情 + */ +export const getGrowthPathDetail = (pathId: number) => { + return request.get(`/api/v1/manager/growth-paths/${pathId}`) +} + /** * 创建成长路径 */ -export const createGrowthPath = (data: { - positionId: number - name: string - description?: string - courses: Array<{ - courseId: number - isRequired: boolean - prerequisites?: number[] - estimatedDays?: number - }> -}) => { - return request.post('/api/v1/manager/growth-paths', data) +export const createGrowthPath = (data: CreateGrowthPathRequest) => { + return request.post<{ success: boolean; message: string; id: number }>('/api/v1/manager/growth-paths', data) } /** * 更新成长路径 */ -export const updateGrowthPath = (pathId: number, data: { - name?: string - description?: string - courses?: Array<{ - courseId: number - isRequired: boolean - prerequisites?: number[] - estimatedDays?: number - }> -}) => { - return request.put(`/api/v1/manager/growth-paths/${pathId}`, data) +export const updateGrowthPath = (pathId: number, data: UpdateGrowthPathRequest) => { + return request.put<{ success: boolean; message: string }>(`/api/v1/manager/growth-paths/${pathId}`, data) +} + +/** + * 删除成长路径 + */ +export const deleteGrowthPath = (pathId: number) => { + return request.delete<{ success: boolean; message: string }>(`/api/v1/manager/growth-paths/${pathId}`) } /** diff --git a/frontend/src/api/trainee/index.ts b/frontend/src/api/trainee/index.ts index 6b9b0e7..b6a9a59 100644 --- a/frontend/src/api/trainee/index.ts +++ b/frontend/src/api/trainee/index.ts @@ -40,7 +40,48 @@ export interface CourseMaterial { 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 { id: number courseId: number @@ -54,7 +95,7 @@ export interface GrowthPathNode { order: number } -// 成长路径 +// 兼容旧接口 export interface GrowthPath { id: number name: string @@ -226,10 +267,35 @@ export const markMaterialCompleted = (materialId: number) => { } /** - * 获取成长路径 + * 获取成长路径(学员端) */ -export const getGrowthPath = () => { - return request.get('/api/v1/trainee/growth-path') +export const getGrowthPath = (positionId?: number) => { + return request.get('/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 + }) } /** diff --git a/frontend/src/views/trainee/growth-path.vue b/frontend/src/views/trainee/growth-path.vue index 48305eb..bd69c6c 100644 --- a/frontend/src/views/trainee/growth-path.vue +++ b/frontend/src/views/trainee/growth-path.vue @@ -363,7 +363,7 @@