feat: 实现成长路径功能
All checks were successful
continuous-integration/drone/push Build is passing

- 新增数据库表: 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:
yuliang_guo
2026-01-30 15:37:14 +08:00
parent d44111e712
commit b4906c543b
11 changed files with 1816 additions and 154 deletions

View File

@@ -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"]

View 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))

View 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='用户节点完成记录表';

View File

@@ -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",
]

View File

@@ -224,9 +224,19 @@ class GrowthPath(BaseModel, SoftDeleteMixin):
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}]"
)
# 预计时长
@@ -242,6 +252,11 @@ class GrowthPath(BaseModel, SoftDeleteMixin):
Integer, default=0, nullable=False, comment="排序顺序"
)
# 关联关系
nodes: Mapped[List["GrowthPathNode"]] = relationship(
"GrowthPathNode", back_populates="growth_path", cascade="all, delete-orphan"
)
class MaterialKnowledgePoint(BaseModel, SoftDeleteMixin):
"""

View 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")

View 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")

View 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()

View File

@@ -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
}
// 成长路径配置(新版)
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<GrowthPathConfig>(`/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<GrowthPathConfig>('/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<GrowthPathConfig>(`/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}`)
}
/**

View File

@@ -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<GrowthPath>('/api/v1/trainee/growth-path')
export const getGrowthPath = (positionId?: number) => {
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
})
}
/**

View File

@@ -363,7 +363,7 @@
</template>
<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 { ElMessage } from 'element-plus'
import {
@@ -374,7 +374,8 @@ import {
Cpu // 机器人图标
} from '@element-plus/icons-vue'
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'
const router = useRouter()
@@ -451,100 +452,73 @@ const expPercentage = computed(() => {
return Math.round((userInfo.value.exp / userInfo.value.nextLevelExp) * 100)
})
// 成长路径数据
const growthPath = ref([
{
name: '基础阶段',
completed: 3,
total: 3,
nodes: [
{
id: 1,
title: '机构文化与服务理念',
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: '高级阶段',
// 成长路径数据从API加载
const growthPathData = ref<TraineeGrowthPath | null>(null)
const growthPathLoading = ref(false)
// 将API数据转换为前端展示格式
const growthPath = computed(() => {
if (!growthPathData.value || !growthPathData.value.stages) {
// 返回默认空数据或占位数据
return [{
name: '暂无成长路径',
completed: 0,
total: 3,
nodes: [
{
id: 8,
title: '轻医美项目咨询',
description: '掌握轻医美项目的专业咨询技能',
status: 'locked',
progress: 0
},
{
id: 9,
title: '团队协作与管理',
description: '学习团队协作和基础管理技能',
status: 'locked',
progress: 0
},
{
id: 10,
title: '高级美容技术',
description: '掌握高级美容技术和创新方法',
status: 'locked',
progress: 0
total: 0,
nodes: []
}]
}
]
return growthPathData.value.stages.map(stage => ({
name: stage.name,
completed: stage.completed,
total: stage.total,
nodes: stage.nodes.map(node => ({
id: node.id,
courseId: node.course_id,
title: node.title,
description: node.description || '',
status: node.status, // locked/unlocked/in_progress/completed
progress: node.progress
}))
}))
})
/**
* 加载成长路径数据
*/
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
}
if (node.status === 'completed') {
ElMessage.info(`${node.title} 已完成`)
return
// 跳转到课程详情页
if (node.courseId) {
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 loadGrowthPath()
nextTick(() => {
initRadarChart()
})