From 0b7c07eb7f9bf5f25991001cae2dc0fcf648d489 Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Sat, 31 Jan 2026 10:03:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E9=94=99=E8=AF=AF=E8=AF=A6=E7=BB=86=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/app/api/v1/endpoints/growth_path.py | 468 ++--- backend/app/main.py | 17 +- .../app/migrations/add_growth_path_tables.sql | 236 +-- backend/app/models/growth_path.py | 412 ++--- backend/app/schemas/growth_path.py | 452 ++--- backend/app/services/growth_path_service.py | 1642 ++++++++--------- backend/app/utils/__init__.py | 28 +- backend/app/utils/score_distributor.py | 436 ++--- backend/scripts/fix_exam_scores.py | 402 ++-- frontend/src/utils/scoreFormatter.ts | 308 ++-- .../views/manager/growth-path-management.vue | 148 +- 11 files changed, 2282 insertions(+), 2267 deletions(-) diff --git a/backend/app/api/v1/endpoints/growth_path.py b/backend/app/api/v1/endpoints/growth_path.py index 830a8cb..25a1c0a 100644 --- a/backend/app/api/v1/endpoints/growth_path.py +++ b/backend/app/api/v1/endpoints/growth_path.py @@ -1,234 +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)) +""" +成长路径 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/main.py b/backend/app/main.py index 2cc0e3c..3cd56f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,10 +2,11 @@ import logging from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles +from fastapi.exceptions import RequestValidationError import json import os @@ -131,6 +132,20 @@ os.makedirs(upload_path, exist_ok=True) app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads") +# 请求验证错误处理 (422) +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """处理请求验证错误,记录详细日志""" + logger.error(f"请求验证错误 [{request.method} {request.url.path}]: {exc.errors()}") + return JSONResponse( + status_code=422, + content={ + "detail": exc.errors(), + "body": exc.body if hasattr(exc, 'body') else None, + }, + ) + + # 全局异常处理 @app.exception_handler(Exception) async def global_exception_handler(request, exc): diff --git a/backend/app/migrations/add_growth_path_tables.sql b/backend/app/migrations/add_growth_path_tables.sql index 6324517..0441686 100644 --- a/backend/app/migrations/add_growth_path_tables.sql +++ b/backend/app/migrations/add_growth_path_tables.sql @@ -1,118 +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='用户节点完成记录表'; +-- 成长路径功能数据库迁移脚本 +-- 创建时间: 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/growth_path.py b/backend/app/models/growth_path.py index 3b9e2d8..bab1161 100644 --- a/backend/app/models/growth_path.py +++ b/backend/app/models/growth_path.py @@ -1,206 +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") +""" +成长路径相关数据库模型 +""" +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 index 583ab99..8c1ecfd 100644 --- a/backend/app/schemas/growth_path.py +++ b/backend/app/schemas/growth_path.py @@ -1,226 +1,226 @@ -""" -成长路径相关 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(兼容旧版)") - position_ids: Optional[List[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 - position_ids: Optional[List[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") +""" +成长路径相关 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(兼容旧版)") + position_ids: Optional[List[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 + position_ids: Optional[List[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 index f0789f4..5f16dd0 100644 --- a/backend/app/services/growth_path_service.py +++ b/backend/app/services/growth_path_service.py @@ -1,821 +1,821 @@ -""" -成长路径服务 -""" -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.models.position_member import PositionMember -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}' 已存在") - - # 处理岗位关联:优先使用 position_ids,兼容 position_id - position_ids = data.position_ids - position_id = data.position_id - if position_ids is None and position_id is not None: - position_ids = [position_id] - elif position_ids and not position_id: - position_id = position_ids[0] if position_ids else None - - # 创建成长路径 - growth_path = GrowthPath( - name=data.name, - description=data.description, - target_role=data.target_role, - position_id=position_id, - position_ids=position_ids, - 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']] - - # 处理 position_ids 和 position_id 的兼容 - if 'position_ids' in update_data and update_data['position_ids']: - update_data['position_id'] = update_data['position_ids'][0] - elif 'position_id' in update_data and update_data['position_id'] and 'position_ids' not in update_data: - update_data['position_ids'] = [update_data['position_id']] - - 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_ids = growth_path.position_ids or [] - # 兼容旧数据 - if not position_ids and growth_path.position_id: - position_ids = [growth_path.position_id] - - position_names = [] - for pid in position_ids: - position = await db.get(Position, pid) - if position: - position_names.append(position.name) - - # 兼容旧版 position_name(取第一个) - position_name = position_names[0] if position_names else None - - # 获取课程名称 - 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_ids": position_ids, - "position_name": position_name, - "position_names": position_names, - "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_ids = path.position_ids or [] - if not position_ids and path.position_id: - position_ids = [path.position_id] - - position_names = [] - for pid in position_ids: - position = await db.get(Position, pid) - if position: - position_names.append(position.name) - - position_name = position_names[0] if position_names else None - - # 获取节点数量 - 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_ids": position_ids, - "position_name": position_name, - "position_names": position_names, - "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,返回该岗位的成长路径 - 否则根据用户岗位自动匹配 - """ - # 如果没有指定岗位,获取用户的岗位列表 - user_position_ids = [] - if not position_id: - pos_result = await db.execute( - select(PositionMember.position_id).where( - and_( - PositionMember.user_id == user_id, - PositionMember.is_deleted == False - ) - ) - ) - user_position_ids = [row[0] for row in pos_result.fetchall()] - logger.info(f"用户 {user_id} 的岗位列表: {user_position_ids}") - - # 查找成长路径 - query = select(GrowthPath).where( - and_( - GrowthPath.is_deleted == False, - GrowthPath.is_active == True - ) - ) - - # 按 sort_order 排序获取所有激活的成长路径 - query = query.order_by(GrowthPath.sort_order) - result = await db.execute(query) - all_paths = result.scalars().all() - - growth_path = None - - if position_id: - # 如果指定了岗位ID,查找匹配的路径 - for path in all_paths: - # 检查 position_ids (多岗位) 或 position_id (单岗位) - path_position_ids = path.position_ids or [] - if path.position_id: - path_position_ids = list(set(path_position_ids + [path.position_id])) - if position_id in path_position_ids: - growth_path = path - break - elif user_position_ids: - # 根据用户岗位匹配 - for path in all_paths: - path_position_ids = path.position_ids or [] - if path.position_id: - path_position_ids = list(set(path_position_ids + [path.position_id])) - # 检查用户岗位是否与路径岗位有交集 - if any(pid in path_position_ids for pid in user_position_ids): - growth_path = path - logger.info(f"匹配到成长路径: {path.id}, 路径岗位: {path_position_ids}") - break - else: - # 没有岗位限制,返回第一个 - growth_path = all_paths[0] if all_paths else 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 - - logger.info(f"成长路径 {growth_path.id} 返回 {len(stages)} 个阶段, {total_nodes} 个节点") - for s in stages: - logger.info(f" 阶段 '{s.name}': {len(s.nodes)} 个节点") - - 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() +""" +成长路径服务 +""" +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.models.position_member import PositionMember +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}' 已存在") + + # 处理岗位关联:优先使用 position_ids,兼容 position_id + position_ids = data.position_ids + position_id = data.position_id + if position_ids is None and position_id is not None: + position_ids = [position_id] + elif position_ids and not position_id: + position_id = position_ids[0] if position_ids else None + + # 创建成长路径 + growth_path = GrowthPath( + name=data.name, + description=data.description, + target_role=data.target_role, + position_id=position_id, + position_ids=position_ids, + 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']] + + # 处理 position_ids 和 position_id 的兼容 + if 'position_ids' in update_data and update_data['position_ids']: + update_data['position_id'] = update_data['position_ids'][0] + elif 'position_id' in update_data and update_data['position_id'] and 'position_ids' not in update_data: + update_data['position_ids'] = [update_data['position_id']] + + 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_ids = growth_path.position_ids or [] + # 兼容旧数据 + if not position_ids and growth_path.position_id: + position_ids = [growth_path.position_id] + + position_names = [] + for pid in position_ids: + position = await db.get(Position, pid) + if position: + position_names.append(position.name) + + # 兼容旧版 position_name(取第一个) + position_name = position_names[0] if position_names else None + + # 获取课程名称 + 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_ids": position_ids, + "position_name": position_name, + "position_names": position_names, + "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_ids = path.position_ids or [] + if not position_ids and path.position_id: + position_ids = [path.position_id] + + position_names = [] + for pid in position_ids: + position = await db.get(Position, pid) + if position: + position_names.append(position.name) + + position_name = position_names[0] if position_names else None + + # 获取节点数量 + 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_ids": position_ids, + "position_name": position_name, + "position_names": position_names, + "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,返回该岗位的成长路径 + 否则根据用户岗位自动匹配 + """ + # 如果没有指定岗位,获取用户的岗位列表 + user_position_ids = [] + if not position_id: + pos_result = await db.execute( + select(PositionMember.position_id).where( + and_( + PositionMember.user_id == user_id, + PositionMember.is_deleted == False + ) + ) + ) + user_position_ids = [row[0] for row in pos_result.fetchall()] + logger.info(f"用户 {user_id} 的岗位列表: {user_position_ids}") + + # 查找成长路径 + query = select(GrowthPath).where( + and_( + GrowthPath.is_deleted == False, + GrowthPath.is_active == True + ) + ) + + # 按 sort_order 排序获取所有激活的成长路径 + query = query.order_by(GrowthPath.sort_order) + result = await db.execute(query) + all_paths = result.scalars().all() + + growth_path = None + + if position_id: + # 如果指定了岗位ID,查找匹配的路径 + for path in all_paths: + # 检查 position_ids (多岗位) 或 position_id (单岗位) + path_position_ids = path.position_ids or [] + if path.position_id: + path_position_ids = list(set(path_position_ids + [path.position_id])) + if position_id in path_position_ids: + growth_path = path + break + elif user_position_ids: + # 根据用户岗位匹配 + for path in all_paths: + path_position_ids = path.position_ids or [] + if path.position_id: + path_position_ids = list(set(path_position_ids + [path.position_id])) + # 检查用户岗位是否与路径岗位有交集 + if any(pid in path_position_ids for pid in user_position_ids): + growth_path = path + logger.info(f"匹配到成长路径: {path.id}, 路径岗位: {path_position_ids}") + break + else: + # 没有岗位限制,返回第一个 + growth_path = all_paths[0] if all_paths else 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 + + logger.info(f"成长路径 {growth_path.id} 返回 {len(stages)} 个阶段, {total_nodes} 个节点") + for s in stages: + logger.info(f" 阶段 '{s.name}': {len(s.nodes)} 个节点") + + 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/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index ab2286d..d3b54ba 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -1,14 +1,14 @@ -""" -工具模块 -""" -from app.utils.score_distributor import ( - ScoreDistributor, - distribute_scores, - get_question_score, -) - -__all__ = [ - "ScoreDistributor", - "distribute_scores", - "get_question_score", -] +""" +工具模块 +""" +from app.utils.score_distributor import ( + ScoreDistributor, + distribute_scores, + get_question_score, +) + +__all__ = [ + "ScoreDistributor", + "distribute_scores", + "get_question_score", +] diff --git a/backend/app/utils/score_distributor.py b/backend/app/utils/score_distributor.py index dced455..5cd195a 100644 --- a/backend/app/utils/score_distributor.py +++ b/backend/app/utils/score_distributor.py @@ -1,218 +1,218 @@ -""" -分数分配工具 - -解决题目分数无法整除的问题,确保: -1. 所有题目分数之和精确等于总分 -2. 题目分数差异最小化(最多相差1分) -3. 支持整数分配和小数分配两种模式 -""" -from typing import List, Tuple -from decimal import Decimal, ROUND_HALF_UP -import math - - -class ScoreDistributor: - """ - 智能分数分配器 - - 使用示例: - distributor = ScoreDistributor(total_score=100, question_count=6) - scores = distributor.distribute() - # 结果: [17, 17, 17, 17, 16, 16] 总和=100 - """ - - def __init__(self, total_score: float, question_count: int): - """ - 初始化分配器 - - Args: - total_score: 总分(如 100) - question_count: 题目数量(如 6) - """ - if question_count <= 0: - raise ValueError("题目数量必须大于0") - if total_score <= 0: - raise ValueError("总分必须大于0") - - self.total_score = total_score - self.question_count = question_count - - def distribute_integer(self) -> List[int]: - """ - 整数分配模式 - - 将总分分配为整数,前面的题目分数可能比后面的多1分 - - Returns: - 分数列表,如 [17, 17, 17, 17, 16, 16] - - 示例: - 100分 / 6题 = [17, 17, 17, 17, 16, 16] - 100分 / 7题 = [15, 15, 14, 14, 14, 14, 14] - """ - total = int(self.total_score) - count = self.question_count - - # 基础分数(向下取整) - base_score = total // count - # 需要额外加1分的题目数量 - extra_count = total % count - - # 生成分数列表 - scores = [] - for i in range(count): - if i < extra_count: - scores.append(base_score + 1) - else: - scores.append(base_score) - - return scores - - def distribute_decimal(self, decimal_places: int = 1) -> List[float]: - """ - 小数分配模式 - - 将总分分配为小数,最后一题用于补齐差额 - - Args: - decimal_places: 小数位数,默认1位 - - Returns: - 分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5] - """ - count = self.question_count - - # 计算每题分数并四舍五入 - per_score = self.total_score / count - rounded_score = round(per_score, decimal_places) - - # 前 n-1 题使用四舍五入的分数 - scores = [rounded_score] * (count - 1) - - # 最后一题用总分减去前面的和,确保总分精确 - last_score = round(self.total_score - sum(scores), decimal_places) - scores.append(last_score) - - return scores - - def distribute(self, mode: str = "integer") -> List[float]: - """ - 分配分数 - - Args: - mode: 分配模式 - - "integer": 整数分配(推荐) - - "decimal": 小数分配 - - "decimal_1": 保留1位小数 - - "decimal_2": 保留2位小数 - - Returns: - 分数列表 - """ - if mode == "integer": - return [float(s) for s in self.distribute_integer()] - elif mode == "decimal" or mode == "decimal_1": - return self.distribute_decimal(1) - elif mode == "decimal_2": - return self.distribute_decimal(2) - else: - return [float(s) for s in self.distribute_integer()] - - def get_score_for_question(self, question_index: int, mode: str = "integer") -> float: - """ - 获取指定题目的分数 - - Args: - question_index: 题目索引(从0开始) - mode: 分配模式 - - Returns: - 该题目的分数 - """ - scores = self.distribute(mode) - if 0 <= question_index < len(scores): - return scores[question_index] - raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})") - - def validate(self) -> Tuple[bool, str]: - """ - 验证分配结果 - - Returns: - (是否有效, 信息) - """ - scores = self.distribute() - total = sum(scores) - - if abs(total - self.total_score) < 0.01: - return True, f"分配有效:{scores},总分={total}" - else: - return False, f"分配无效:{scores},总分={total},期望={self.total_score}" - - @staticmethod - def format_score(score: float, decimal_places: int = 1) -> str: - """ - 格式化分数显示 - - Args: - score: 分数 - decimal_places: 小数位数 - - Returns: - 格式化的分数字符串 - """ - if score == int(score): - return str(int(score)) - return f"{score:.{decimal_places}f}" - - @staticmethod - def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float: - """ - 计算及格分数 - - Args: - total_score: 总分 - pass_rate: 及格率,默认60% - - Returns: - 及格分数(整数) - """ - return math.ceil(total_score * pass_rate) - - -def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]: - """ - 便捷函数:分配分数 - - Args: - total_score: 总分 - question_count: 题目数量 - mode: 分配模式(integer/decimal) - - Returns: - 分数列表 - """ - distributor = ScoreDistributor(total_score, question_count) - return distributor.distribute(mode) - - -def get_question_score( - total_score: float, - question_count: int, - question_index: int, - mode: str = "integer" -) -> float: - """ - 便捷函数:获取指定题目的分数 - - Args: - total_score: 总分 - question_count: 题目数量 - question_index: 题目索引(从0开始) - mode: 分配模式 - - Returns: - 该题目的分数 - """ - distributor = ScoreDistributor(total_score, question_count) - return distributor.get_score_for_question(question_index, mode) +""" +分数分配工具 + +解决题目分数无法整除的问题,确保: +1. 所有题目分数之和精确等于总分 +2. 题目分数差异最小化(最多相差1分) +3. 支持整数分配和小数分配两种模式 +""" +from typing import List, Tuple +from decimal import Decimal, ROUND_HALF_UP +import math + + +class ScoreDistributor: + """ + 智能分数分配器 + + 使用示例: + distributor = ScoreDistributor(total_score=100, question_count=6) + scores = distributor.distribute() + # 结果: [17, 17, 17, 17, 16, 16] 总和=100 + """ + + def __init__(self, total_score: float, question_count: int): + """ + 初始化分配器 + + Args: + total_score: 总分(如 100) + question_count: 题目数量(如 6) + """ + if question_count <= 0: + raise ValueError("题目数量必须大于0") + if total_score <= 0: + raise ValueError("总分必须大于0") + + self.total_score = total_score + self.question_count = question_count + + def distribute_integer(self) -> List[int]: + """ + 整数分配模式 + + 将总分分配为整数,前面的题目分数可能比后面的多1分 + + Returns: + 分数列表,如 [17, 17, 17, 17, 16, 16] + + 示例: + 100分 / 6题 = [17, 17, 17, 17, 16, 16] + 100分 / 7题 = [15, 15, 14, 14, 14, 14, 14] + """ + total = int(self.total_score) + count = self.question_count + + # 基础分数(向下取整) + base_score = total // count + # 需要额外加1分的题目数量 + extra_count = total % count + + # 生成分数列表 + scores = [] + for i in range(count): + if i < extra_count: + scores.append(base_score + 1) + else: + scores.append(base_score) + + return scores + + def distribute_decimal(self, decimal_places: int = 1) -> List[float]: + """ + 小数分配模式 + + 将总分分配为小数,最后一题用于补齐差额 + + Args: + decimal_places: 小数位数,默认1位 + + Returns: + 分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5] + """ + count = self.question_count + + # 计算每题分数并四舍五入 + per_score = self.total_score / count + rounded_score = round(per_score, decimal_places) + + # 前 n-1 题使用四舍五入的分数 + scores = [rounded_score] * (count - 1) + + # 最后一题用总分减去前面的和,确保总分精确 + last_score = round(self.total_score - sum(scores), decimal_places) + scores.append(last_score) + + return scores + + def distribute(self, mode: str = "integer") -> List[float]: + """ + 分配分数 + + Args: + mode: 分配模式 + - "integer": 整数分配(推荐) + - "decimal": 小数分配 + - "decimal_1": 保留1位小数 + - "decimal_2": 保留2位小数 + + Returns: + 分数列表 + """ + if mode == "integer": + return [float(s) for s in self.distribute_integer()] + elif mode == "decimal" or mode == "decimal_1": + return self.distribute_decimal(1) + elif mode == "decimal_2": + return self.distribute_decimal(2) + else: + return [float(s) for s in self.distribute_integer()] + + def get_score_for_question(self, question_index: int, mode: str = "integer") -> float: + """ + 获取指定题目的分数 + + Args: + question_index: 题目索引(从0开始) + mode: 分配模式 + + Returns: + 该题目的分数 + """ + scores = self.distribute(mode) + if 0 <= question_index < len(scores): + return scores[question_index] + raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})") + + def validate(self) -> Tuple[bool, str]: + """ + 验证分配结果 + + Returns: + (是否有效, 信息) + """ + scores = self.distribute() + total = sum(scores) + + if abs(total - self.total_score) < 0.01: + return True, f"分配有效:{scores},总分={total}" + else: + return False, f"分配无效:{scores},总分={total},期望={self.total_score}" + + @staticmethod + def format_score(score: float, decimal_places: int = 1) -> str: + """ + 格式化分数显示 + + Args: + score: 分数 + decimal_places: 小数位数 + + Returns: + 格式化的分数字符串 + """ + if score == int(score): + return str(int(score)) + return f"{score:.{decimal_places}f}" + + @staticmethod + def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float: + """ + 计算及格分数 + + Args: + total_score: 总分 + pass_rate: 及格率,默认60% + + Returns: + 及格分数(整数) + """ + return math.ceil(total_score * pass_rate) + + +def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]: + """ + 便捷函数:分配分数 + + Args: + total_score: 总分 + question_count: 题目数量 + mode: 分配模式(integer/decimal) + + Returns: + 分数列表 + """ + distributor = ScoreDistributor(total_score, question_count) + return distributor.distribute(mode) + + +def get_question_score( + total_score: float, + question_count: int, + question_index: int, + mode: str = "integer" +) -> float: + """ + 便捷函数:获取指定题目的分数 + + Args: + total_score: 总分 + question_count: 题目数量 + question_index: 题目索引(从0开始) + mode: 分配模式 + + Returns: + 该题目的分数 + """ + distributor = ScoreDistributor(total_score, question_count) + return distributor.get_score_for_question(question_index, mode) diff --git a/backend/scripts/fix_exam_scores.py b/backend/scripts/fix_exam_scores.py index 4b85cf3..c1bf7fe 100644 --- a/backend/scripts/fix_exam_scores.py +++ b/backend/scripts/fix_exam_scores.py @@ -1,201 +1,201 @@ -#!/usr/bin/env python3 -""" -修复历史考试的小数分数问题 - -将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数 -使用智能整数分配算法,确保所有题目分数之和等于总分 - -使用方法: - # 在后端容器中执行 - cd /app - python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改 - python scripts/fix_exam_scores.py # 实际执行修复 -""" - -import sys -import json -import argparse -from decimal import Decimal - -# 添加项目路径 -sys.path.insert(0, '/app') - -def distribute_integer_scores(total_score: float, question_count: int) -> list: - """ - 整数分配分数 - - 将总分分配为整数,前面的题目分数可能比后面的多1分 - - 示例: - 100分 / 6题 = [17, 17, 17, 17, 16, 16] - 100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5] - """ - total = int(total_score) - count = question_count - - # 基础分数(向下取整) - base_score = total // count - # 需要额外加1分的题目数量 - extra_count = total % count - - # 生成分数列表 - scores = [] - for i in range(count): - if i < extra_count: - scores.append(base_score + 1) - else: - scores.append(base_score) - - return scores - - -def is_decimal_score(score) -> bool: - """检查分数是否是小数(非整数)""" - if score is None: - return False - try: - score_float = float(score) - return score_float != int(score_float) - except (ValueError, TypeError): - return False - - -def fix_exam_scores(dry_run: bool = True, db_url: str = None): - """ - 修复考试分数 - - Args: - dry_run: 如果为 True,只预览不实际修改 - db_url: 数据库连接字符串,如果为 None 则从环境变量读取 - """ - import os - from sqlalchemy import create_engine, text - from sqlalchemy.orm import sessionmaker - - # 获取数据库连接 - if db_url is None: - db_url = os.environ.get('DATABASE_URL') - if not db_url: - # 尝试从配置文件读取 - try: - from app.core.config import settings - db_url = settings.DATABASE_URL - except: - print("错误:无法获取数据库连接字符串") - print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行") - sys.exit(1) - - # 将 mysql+aiomysql:// 转换为 mysql+pymysql:// - if 'aiomysql' in db_url: - db_url = db_url.replace('aiomysql', 'pymysql') - - print(f"连接数据库...") - engine = create_engine(db_url, echo=False) - Session = sessionmaker(bind=engine) - session = Session() - - try: - # 查询所有考试记录 - result = session.execute(text(""" - SELECT id, user_id, course_id, exam_name, question_count, total_score, questions - FROM exams - WHERE questions IS NOT NULL - ORDER BY id DESC - """)) - - exams = result.fetchall() - print(f"找到 {len(exams)} 条考试记录") - - fixed_count = 0 - skipped_count = 0 - error_count = 0 - - for exam in exams: - exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam - - try: - # 解析 questions JSON - if isinstance(questions_json, str): - questions = json.loads(questions_json) - else: - questions = questions_json - - if not questions or not isinstance(questions, list): - skipped_count += 1 - continue - - # 检查是否有小数分数 - has_decimal = False - for q in questions: - if 'score' in q and is_decimal_score(q['score']): - has_decimal = True - break - - if not has_decimal: - skipped_count += 1 - continue - - # 计算新的整数分数 - actual_count = len(questions) - actual_total = total_score or 100 - new_scores = distribute_integer_scores(actual_total, actual_count) - - # 更新每道题的分数 - old_scores = [q.get('score', 0) for q in questions] - for i, q in enumerate(questions): - q['score'] = new_scores[i] - - # 验证总分 - new_total = sum(new_scores) - if abs(new_total - actual_total) > 0.01: - print(f" 警告:exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}") - error_count += 1 - continue - - if dry_run: - print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}") - print(f" 旧分数: {old_scores[:5]}..." if len(old_scores) > 5 else f" 旧分数: {old_scores}") - print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}") - else: - # 实际更新数据库 - new_json = json.dumps(questions, ensure_ascii=False) - session.execute(text(""" - UPDATE exams SET questions = :questions WHERE id = :exam_id - """), {"questions": new_json, "exam_id": exam_id}) - print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}") - - fixed_count += 1 - - except Exception as e: - print(f" 错误:处理 exam_id={exam_id} 时出错: {e}") - error_count += 1 - continue - - if not dry_run: - session.commit() - print("\n已提交数据库更改") - - print(f"\n=== 统计 ===") - print(f"需要修复: {fixed_count}") - print(f"已跳过(无小数): {skipped_count}") - print(f"错误: {error_count}") - - if dry_run: - print("\n这是预览模式,未实际修改数据库。") - print("如需实际执行,请去掉 --dry-run 参数重新运行。") - - except Exception as e: - print(f"执行失败: {e}") - session.rollback() - raise - finally: - session.close() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题") - parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库") - parser.add_argument("--db-url", type=str, help="数据库连接字符串") - args = parser.parse_args() - - fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url) +#!/usr/bin/env python3 +""" +修复历史考试的小数分数问题 + +将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数 +使用智能整数分配算法,确保所有题目分数之和等于总分 + +使用方法: + # 在后端容器中执行 + cd /app + python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改 + python scripts/fix_exam_scores.py # 实际执行修复 +""" + +import sys +import json +import argparse +from decimal import Decimal + +# 添加项目路径 +sys.path.insert(0, '/app') + +def distribute_integer_scores(total_score: float, question_count: int) -> list: + """ + 整数分配分数 + + 将总分分配为整数,前面的题目分数可能比后面的多1分 + + 示例: + 100分 / 6题 = [17, 17, 17, 17, 16, 16] + 100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5] + """ + total = int(total_score) + count = question_count + + # 基础分数(向下取整) + base_score = total // count + # 需要额外加1分的题目数量 + extra_count = total % count + + # 生成分数列表 + scores = [] + for i in range(count): + if i < extra_count: + scores.append(base_score + 1) + else: + scores.append(base_score) + + return scores + + +def is_decimal_score(score) -> bool: + """检查分数是否是小数(非整数)""" + if score is None: + return False + try: + score_float = float(score) + return score_float != int(score_float) + except (ValueError, TypeError): + return False + + +def fix_exam_scores(dry_run: bool = True, db_url: str = None): + """ + 修复考试分数 + + Args: + dry_run: 如果为 True,只预览不实际修改 + db_url: 数据库连接字符串,如果为 None 则从环境变量读取 + """ + import os + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + + # 获取数据库连接 + if db_url is None: + db_url = os.environ.get('DATABASE_URL') + if not db_url: + # 尝试从配置文件读取 + try: + from app.core.config import settings + db_url = settings.DATABASE_URL + except: + print("错误:无法获取数据库连接字符串") + print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行") + sys.exit(1) + + # 将 mysql+aiomysql:// 转换为 mysql+pymysql:// + if 'aiomysql' in db_url: + db_url = db_url.replace('aiomysql', 'pymysql') + + print(f"连接数据库...") + engine = create_engine(db_url, echo=False) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # 查询所有考试记录 + result = session.execute(text(""" + SELECT id, user_id, course_id, exam_name, question_count, total_score, questions + FROM exams + WHERE questions IS NOT NULL + ORDER BY id DESC + """)) + + exams = result.fetchall() + print(f"找到 {len(exams)} 条考试记录") + + fixed_count = 0 + skipped_count = 0 + error_count = 0 + + for exam in exams: + exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam + + try: + # 解析 questions JSON + if isinstance(questions_json, str): + questions = json.loads(questions_json) + else: + questions = questions_json + + if not questions or not isinstance(questions, list): + skipped_count += 1 + continue + + # 检查是否有小数分数 + has_decimal = False + for q in questions: + if 'score' in q and is_decimal_score(q['score']): + has_decimal = True + break + + if not has_decimal: + skipped_count += 1 + continue + + # 计算新的整数分数 + actual_count = len(questions) + actual_total = total_score or 100 + new_scores = distribute_integer_scores(actual_total, actual_count) + + # 更新每道题的分数 + old_scores = [q.get('score', 0) for q in questions] + for i, q in enumerate(questions): + q['score'] = new_scores[i] + + # 验证总分 + new_total = sum(new_scores) + if abs(new_total - actual_total) > 0.01: + print(f" 警告:exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}") + error_count += 1 + continue + + if dry_run: + print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}") + print(f" 旧分数: {old_scores[:5]}..." if len(old_scores) > 5 else f" 旧分数: {old_scores}") + print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}") + else: + # 实际更新数据库 + new_json = json.dumps(questions, ensure_ascii=False) + session.execute(text(""" + UPDATE exams SET questions = :questions WHERE id = :exam_id + """), {"questions": new_json, "exam_id": exam_id}) + print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}") + + fixed_count += 1 + + except Exception as e: + print(f" 错误:处理 exam_id={exam_id} 时出错: {e}") + error_count += 1 + continue + + if not dry_run: + session.commit() + print("\n已提交数据库更改") + + print(f"\n=== 统计 ===") + print(f"需要修复: {fixed_count}") + print(f"已跳过(无小数): {skipped_count}") + print(f"错误: {error_count}") + + if dry_run: + print("\n这是预览模式,未实际修改数据库。") + print("如需实际执行,请去掉 --dry-run 参数重新运行。") + + except Exception as e: + print(f"执行失败: {e}") + session.rollback() + raise + finally: + session.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题") + parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库") + parser.add_argument("--db-url", type=str, help="数据库连接字符串") + args = parser.parse_args() + + fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url) diff --git a/frontend/src/utils/scoreFormatter.ts b/frontend/src/utils/scoreFormatter.ts index 147d9ee..0815f75 100644 --- a/frontend/src/utils/scoreFormatter.ts +++ b/frontend/src/utils/scoreFormatter.ts @@ -1,154 +1,154 @@ -/** - * 分数格式化工具 - * - * 用于在前端显示分数时进行格式化,避免显示过长的小数 - */ - -/** - * 格式化分数显示 - * - * @param score 分数 - * @param decimalPlaces 小数位数,默认1位 - * @returns 格式化后的分数字符串 - * - * @example - * formatScore(16.666666) // "16.7" - * formatScore(17) // "17" - * formatScore(16.5, 0) // "17" - */ -export function formatScore(score: number, decimalPlaces: number = 1): string { - // 如果是整数,直接返回 - if (Number.isInteger(score)) { - return score.toString() - } - - // 四舍五入到指定小数位 - const rounded = Number(score.toFixed(decimalPlaces)) - - // 如果四舍五入后是整数,去掉小数点 - if (Number.isInteger(rounded)) { - return rounded.toString() - } - - return rounded.toFixed(decimalPlaces) -} - -/** - * 格式化分数显示(带单位) - * - * @param score 分数 - * @param unit 单位,默认"分" - * @returns 格式化后的分数字符串 - * - * @example - * formatScoreWithUnit(16.7) // "16.7分" - * formatScoreWithUnit(100) // "100分" - */ -export function formatScoreWithUnit(score: number, unit: string = '分'): string { - return `${formatScore(score)}${unit}` -} - -/** - * 格式化百分比 - * - * @param value 值(0-1 或 0-100) - * @param isPercent 是否已经是百分比形式(0-100),默认false - * @returns 格式化后的百分比字符串 - * - * @example - * formatPercent(0.8567) // "85.7%" - * formatPercent(85.67, true) // "85.7%" - */ -export function formatPercent(value: number, isPercent: boolean = false): string { - const percent = isPercent ? value : value * 100 - return `${formatScore(percent)}%` -} - -/** - * 计算及格分数 - * - * @param totalScore 总分 - * @param passRate 及格率,默认0.6 - * @returns 及格分数(向上取整) - */ -export function calculatePassScore(totalScore: number, passRate: number = 0.6): number { - return Math.ceil(totalScore * passRate) -} - -/** - * 判断是否及格 - * - * @param score 得分 - * @param passScore 及格分数 - * @returns 是否及格 - */ -export function isPassed(score: number, passScore: number): boolean { - return score >= passScore -} - -/** - * 获取分数等级 - * - * @param score 得分 - * @param totalScore 总分 - * @returns 等级: 'excellent' | 'good' | 'pass' | 'fail' - */ -export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' { - const ratio = score / totalScore - - if (ratio >= 0.9) return 'excellent' - if (ratio >= 0.75) return 'good' - if (ratio >= 0.6) return 'pass' - return 'fail' -} - -/** - * 获取分数等级对应的颜色 - * - * @param level 等级 - * @returns 颜色值 - */ -export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string { - const colors = { - excellent: '#67c23a', // 绿色 - good: '#409eff', // 蓝色 - pass: '#e6a23c', // 橙色 - fail: '#f56c6c', // 红色 - } - return colors[level] -} - -/** - * 智能分配分数(前端预览用) - * - * @param totalScore 总分 - * @param questionCount 题目数量 - * @returns 分数数组 - * - * @example - * distributeScores(100, 6) // [17, 17, 17, 17, 16, 16] - */ -export function distributeScores(totalScore: number, questionCount: number): number[] { - if (questionCount <= 0) return [] - - const baseScore = Math.floor(totalScore / questionCount) - const extraCount = totalScore % questionCount - - const scores: number[] = [] - for (let i = 0; i < questionCount; i++) { - scores.push(i < extraCount ? baseScore + 1 : baseScore) - } - - return scores -} - -export default { - formatScore, - formatScoreWithUnit, - formatPercent, - calculatePassScore, - isPassed, - getScoreLevel, - getScoreLevelColor, - distributeScores, -} +/** + * 分数格式化工具 + * + * 用于在前端显示分数时进行格式化,避免显示过长的小数 + */ + +/** + * 格式化分数显示 + * + * @param score 分数 + * @param decimalPlaces 小数位数,默认1位 + * @returns 格式化后的分数字符串 + * + * @example + * formatScore(16.666666) // "16.7" + * formatScore(17) // "17" + * formatScore(16.5, 0) // "17" + */ +export function formatScore(score: number, decimalPlaces: number = 1): string { + // 如果是整数,直接返回 + if (Number.isInteger(score)) { + return score.toString() + } + + // 四舍五入到指定小数位 + const rounded = Number(score.toFixed(decimalPlaces)) + + // 如果四舍五入后是整数,去掉小数点 + if (Number.isInteger(rounded)) { + return rounded.toString() + } + + return rounded.toFixed(decimalPlaces) +} + +/** + * 格式化分数显示(带单位) + * + * @param score 分数 + * @param unit 单位,默认"分" + * @returns 格式化后的分数字符串 + * + * @example + * formatScoreWithUnit(16.7) // "16.7分" + * formatScoreWithUnit(100) // "100分" + */ +export function formatScoreWithUnit(score: number, unit: string = '分'): string { + return `${formatScore(score)}${unit}` +} + +/** + * 格式化百分比 + * + * @param value 值(0-1 或 0-100) + * @param isPercent 是否已经是百分比形式(0-100),默认false + * @returns 格式化后的百分比字符串 + * + * @example + * formatPercent(0.8567) // "85.7%" + * formatPercent(85.67, true) // "85.7%" + */ +export function formatPercent(value: number, isPercent: boolean = false): string { + const percent = isPercent ? value : value * 100 + return `${formatScore(percent)}%` +} + +/** + * 计算及格分数 + * + * @param totalScore 总分 + * @param passRate 及格率,默认0.6 + * @returns 及格分数(向上取整) + */ +export function calculatePassScore(totalScore: number, passRate: number = 0.6): number { + return Math.ceil(totalScore * passRate) +} + +/** + * 判断是否及格 + * + * @param score 得分 + * @param passScore 及格分数 + * @returns 是否及格 + */ +export function isPassed(score: number, passScore: number): boolean { + return score >= passScore +} + +/** + * 获取分数等级 + * + * @param score 得分 + * @param totalScore 总分 + * @returns 等级: 'excellent' | 'good' | 'pass' | 'fail' + */ +export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' { + const ratio = score / totalScore + + if (ratio >= 0.9) return 'excellent' + if (ratio >= 0.75) return 'good' + if (ratio >= 0.6) return 'pass' + return 'fail' +} + +/** + * 获取分数等级对应的颜色 + * + * @param level 等级 + * @returns 颜色值 + */ +export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string { + const colors = { + excellent: '#67c23a', // 绿色 + good: '#409eff', // 蓝色 + pass: '#e6a23c', // 橙色 + fail: '#f56c6c', // 红色 + } + return colors[level] +} + +/** + * 智能分配分数(前端预览用) + * + * @param totalScore 总分 + * @param questionCount 题目数量 + * @returns 分数数组 + * + * @example + * distributeScores(100, 6) // [17, 17, 17, 17, 16, 16] + */ +export function distributeScores(totalScore: number, questionCount: number): number[] { + if (questionCount <= 0) return [] + + const baseScore = Math.floor(totalScore / questionCount) + const extraCount = totalScore % questionCount + + const scores: number[] = [] + for (let i = 0; i < questionCount; i++) { + scores.push(i < extraCount ? baseScore + 1 : baseScore) + } + + return scores +} + +export default { + formatScore, + formatScoreWithUnit, + formatPercent, + calculatePassScore, + isPassed, + getScoreLevel, + getScoreLevelColor, + distributeScores, +} diff --git a/frontend/src/views/manager/growth-path-management.vue b/frontend/src/views/manager/growth-path-management.vue index 675720d..228d7b7 100644 --- a/frontend/src/views/manager/growth-path-management.vue +++ b/frontend/src/views/manager/growth-path-management.vue @@ -2,9 +2,9 @@
@@ -768,10 +768,10 @@ const handleDeletePath = async (row: GrowthPathListItem) => { try { await ElMessageBox.confirm( `确定要删除路径"${row.name}"吗?此操作不可恢复。`, - '删除确认', - { - confirmButtonText: '确定', - cancelButtonText: '取消', + '删除确认', + { + confirmButtonText: '确定', + cancelButtonText: '取消', type: 'warning', } ) @@ -1040,9 +1040,9 @@ onMounted(() => { flex-shrink: 0; .top-section { - background: #fff; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); padding: 16px; h3 { @@ -1059,7 +1059,7 @@ onMounted(() => { flex: 2; .form-row { - display: flex; + display: flex; gap: 16px; margin-bottom: 8px; @@ -1113,9 +1113,9 @@ onMounted(() => { align-items: center; margin-bottom: 12px; padding-bottom: 8px; - border-bottom: 1px solid #ebeef5; + border-bottom: 1px solid #ebeef5; - h3 { + h3 { margin: 0; padding: 0; border: none; @@ -1134,7 +1134,7 @@ onMounted(() => { .stage-order { color: #667eea; - font-weight: 600; + font-weight: 600; font-size: 12px; } } @@ -1150,7 +1150,7 @@ onMounted(() => { gap: 8px; .stat-box { - flex: 1; + flex: 1; text-align: center; padding: 8px 4px; background: #f5f7fa; @@ -1188,8 +1188,8 @@ onMounted(() => { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; .panel-header { display: flex; @@ -1254,7 +1254,7 @@ onMounted(() => { .course-name { display: block; font-size: 13px; - color: #333; + color: #333; font-weight: 500; white-space: nowrap; overflow: hidden; @@ -1266,34 +1266,34 @@ onMounted(() => { font-size: 11px; color: #909399; margin-top: 2px; - } } } } } + } // 右侧已选课程 2/3 .selected-courses-panel { - flex: 1; + flex: 1; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; .panel-header { - display: flex; - justify-content: space-between; - align-items: center; + display: flex; + justify-content: space-between; + align-items: center; padding: 12px 16px; background: #fafafa; border-radius: 8px 8px 0 0; font-weight: 500; - border-bottom: 1px solid #ebeef5; + border-bottom: 1px solid #ebeef5; } .selected-content { - flex: 1; + flex: 1; overflow-y: auto; padding: 12px; border: 2px dashed transparent; @@ -1341,7 +1341,7 @@ onMounted(() => { align-items: center; gap: 8px; padding: 10px; - background: #fff; + background: #fff; border: 1px solid #ebeef5; border-radius: 6px; margin-bottom: 8px; @@ -1359,28 +1359,28 @@ onMounted(() => { flex: 1; min-width: 0; - .node-title { + .node-title { display: block; - font-weight: 500; - color: #333; + font-weight: 500; + color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .node-meta { - display: flex; - align-items: center; + display: flex; + align-items: center; gap: 8px; margin-top: 4px; - font-size: 12px; - color: #909399; + font-size: 12px; + color: #909399; } } .node-actions { - display: flex; - align-items: center; + display: flex; + align-items: center; gap: 8px; } } @@ -1390,12 +1390,12 @@ onMounted(() => { padding: 16px; color: #909399; font-size: 13px; - } } } } } } + } @keyframes pulse { 0%, 100% { opacity: 1; } @@ -1424,10 +1424,10 @@ onMounted(() => { } .editor-bottom { - flex-direction: column; + flex-direction: column; .course-library-panel { - width: 100%; + width: 100%; max-height: 300px; } }