feat: 添加请求验证错误详细日志
All checks were successful
continuous-integration/drone/push Build is passing

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuliang_guo
2026-01-31 10:03:54 +08:00
parent fadeaadd65
commit 0b7c07eb7f
11 changed files with 2282 additions and 2267 deletions

View File

@@ -1,234 +1,234 @@
""" """
成长路径 API 端点 成长路径 API 端点
""" """
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user from app.core.deps import get_db, get_current_user
from app.models.user import User from app.models.user import User
from app.services.growth_path_service import growth_path_service from app.services.growth_path_service import growth_path_service
from app.schemas.growth_path import ( from app.schemas.growth_path import (
GrowthPathCreate, GrowthPathCreate,
GrowthPathUpdate, GrowthPathUpdate,
GrowthPathResponse, GrowthPathResponse,
GrowthPathListResponse, GrowthPathListResponse,
TraineeGrowthPathResponse, TraineeGrowthPathResponse,
UserGrowthPathProgressResponse, UserGrowthPathProgressResponse,
StartGrowthPathRequest, StartGrowthPathRequest,
CompleteNodeRequest, CompleteNodeRequest,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# ===================================================== # =====================================================
# 学员端 API # 学员端 API
# ===================================================== # =====================================================
@router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse]) @router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse])
async def get_trainee_growth_path( async def get_trainee_growth_path(
position_id: Optional[int] = Query(None, description="岗位ID不传则自动匹配"), position_id: Optional[int] = Query(None, description="岗位ID不传则自动匹配"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
获取学员的成长路径(含进度) 获取学员的成长路径(含进度)
返回数据包含: 返回数据包含:
- 成长路径基本信息 - 成长路径基本信息
- 各阶段及节点信息 - 各阶段及节点信息
- 每个节点的学习状态locked/unlocked/in_progress/completed - 每个节点的学习状态locked/unlocked/in_progress/completed
- 每个节点的课程学习进度 - 每个节点的课程学习进度
""" """
try: try:
result = await growth_path_service.get_trainee_growth_path( result = await growth_path_service.get_trainee_growth_path(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
position_id=position_id position_id=position_id
) )
return result return result
except Exception as e: except Exception as e:
logger.error(f"获取成长路径失败: {e}") logger.error(f"获取成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/trainee/growth-path/start") @router.post("/trainee/growth-path/start")
async def start_growth_path( async def start_growth_path(
request: StartGrowthPathRequest, request: StartGrowthPathRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
开始学习成长路径 开始学习成长路径
""" """
try: try:
progress = await growth_path_service.start_growth_path( progress = await growth_path_service.start_growth_path(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
growth_path_id=request.growth_path_id growth_path_id=request.growth_path_id
) )
return { return {
"success": True, "success": True,
"message": "已开始学习成长路径", "message": "已开始学习成长路径",
"progress_id": progress.id, "progress_id": progress.id,
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"开始成长路径失败: {e}") logger.error(f"开始成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/trainee/growth-path/node/complete") @router.post("/trainee/growth-path/node/complete")
async def complete_growth_path_node( async def complete_growth_path_node(
request: CompleteNodeRequest, request: CompleteNodeRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
完成成长路径节点 完成成长路径节点
""" """
try: try:
result = await growth_path_service.complete_node( result = await growth_path_service.complete_node(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
node_id=request.node_id node_id=request.node_id
) )
return result return result
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"完成节点失败: {e}") logger.error(f"完成节点失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ===================================================== # =====================================================
# 管理端 API # 管理端 API
# ===================================================== # =====================================================
@router.get("/manager/growth-paths") @router.get("/manager/growth-paths")
async def list_growth_paths( async def list_growth_paths(
position_id: Optional[int] = Query(None, description="岗位ID筛选"), position_id: Optional[int] = Query(None, description="岗位ID筛选"),
is_active: Optional[bool] = Query(None, description="是否启用"), is_active: Optional[bool] = Query(None, description="是否启用"),
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"), page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
获取成长路径列表(管理端) 获取成长路径列表(管理端)
""" """
try: try:
result = await growth_path_service.list_growth_paths( result = await growth_path_service.list_growth_paths(
db=db, db=db,
position_id=position_id, position_id=position_id,
is_active=is_active, is_active=is_active,
page=page, page=page,
page_size=page_size page_size=page_size
) )
return result return result
except Exception as e: except Exception as e:
logger.error(f"获取成长路径列表失败: {e}") logger.error(f"获取成长路径列表失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/manager/growth-paths") @router.post("/manager/growth-paths")
async def create_growth_path( async def create_growth_path(
data: GrowthPathCreate, data: GrowthPathCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
创建成长路径(管理端) 创建成长路径(管理端)
""" """
try: try:
growth_path = await growth_path_service.create_growth_path( growth_path = await growth_path_service.create_growth_path(
db=db, db=db,
data=data, data=data,
created_by=current_user.id created_by=current_user.id
) )
return { return {
"success": True, "success": True,
"message": "创建成功", "message": "创建成功",
"id": growth_path.id, "id": growth_path.id,
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"创建成长路径失败: {e}") logger.error(f"创建成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/manager/growth-paths/{path_id}") @router.get("/manager/growth-paths/{path_id}")
async def get_growth_path( async def get_growth_path(
path_id: int, path_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
获取成长路径详情(管理端) 获取成长路径详情(管理端)
""" """
try: try:
result = await growth_path_service.get_growth_path(db=db, path_id=path_id) result = await growth_path_service.get_growth_path(db=db, path_id=path_id)
if not result: if not result:
raise HTTPException(status_code=404, detail="成长路径不存在") raise HTTPException(status_code=404, detail="成长路径不存在")
return result return result
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"获取成长路径详情失败: {e}") logger.error(f"获取成长路径详情失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put("/manager/growth-paths/{path_id}") @router.put("/manager/growth-paths/{path_id}")
async def update_growth_path( async def update_growth_path(
path_id: int, path_id: int,
data: GrowthPathUpdate, data: GrowthPathUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
更新成长路径(管理端) 更新成长路径(管理端)
""" """
try: try:
await growth_path_service.update_growth_path( await growth_path_service.update_growth_path(
db=db, db=db,
path_id=path_id, path_id=path_id,
data=data data=data
) )
return { return {
"success": True, "success": True,
"message": "更新成功", "message": "更新成功",
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"更新成长路径失败: {e}") logger.error(f"更新成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/manager/growth-paths/{path_id}") @router.delete("/manager/growth-paths/{path_id}")
async def delete_growth_path( async def delete_growth_path(
path_id: int, path_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
删除成长路径(管理端) 删除成长路径(管理端)
""" """
try: try:
await growth_path_service.delete_growth_path(db=db, path_id=path_id) await growth_path_service.delete_growth_path(db=db, path_id=path_id)
return { return {
"success": True, "success": True,
"message": "删除成功", "message": "删除成功",
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"删除成长路径失败: {e}") logger.error(f"删除成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -2,10 +2,11 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
import json import json
import os import os
@@ -131,6 +132,20 @@ os.makedirs(upload_path, exist_ok=True)
app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads") app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads")
# 请求验证错误处理 (422)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""处理请求验证错误,记录详细日志"""
logger.error(f"请求验证错误 [{request.method} {request.url.path}]: {exc.errors()}")
return JSONResponse(
status_code=422,
content={
"detail": exc.errors(),
"body": exc.body if hasattr(exc, 'body') else None,
},
)
# 全局异常处理 # 全局异常处理
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def global_exception_handler(request, exc): async def global_exception_handler(request, exc):

View File

@@ -1,118 +1,118 @@
-- 成长路径功能数据库迁移脚本 -- 成长路径功能数据库迁移脚本
-- 创建时间: 2026-01-30 -- 创建时间: 2026-01-30
-- ===================================================== -- =====================================================
-- 1. 修改 growth_paths 表,添加岗位关联 -- 1. 修改 growth_paths 表,添加岗位关联
-- ===================================================== -- =====================================================
ALTER TABLE 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 position_id INT NULL COMMENT '关联岗位ID' AFTER target_role,
ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses, ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses,
ADD INDEX idx_position_id (position_id); ADD INDEX idx_position_id (position_id);
-- ===================================================== -- =====================================================
-- 2. 创建成长路径节点表 -- 2. 创建成长路径节点表
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS growth_path_nodes ( CREATE TABLE IF NOT EXISTS growth_path_nodes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
growth_path_id INT NOT NULL COMMENT '成长路径ID', growth_path_id INT NOT NULL COMMENT '成长路径ID',
course_id INT NOT NULL COMMENT '课程ID', course_id INT NOT NULL COMMENT '课程ID',
stage_name VARCHAR(100) NULL COMMENT '所属阶段名称', stage_name VARCHAR(100) NULL COMMENT '所属阶段名称',
title VARCHAR(200) NOT NULL COMMENT '节点标题', title VARCHAR(200) NOT NULL COMMENT '节点标题',
description TEXT NULL COMMENT '节点描述', description TEXT NULL COMMENT '节点描述',
order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序', order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序',
is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修', is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修',
prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]', prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]',
estimated_days INT DEFAULT 7 COMMENT '预计学习天数', estimated_days INT DEFAULT 7 COMMENT '预计学习天数',
-- 软删除 -- 软删除
is_deleted BOOLEAN DEFAULT FALSE NOT NULL, is_deleted BOOLEAN DEFAULT FALSE NOT NULL,
deleted_at DATETIME NULL, deleted_at DATETIME NULL,
-- 时间戳 -- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_growth_path_id (growth_path_id),
INDEX idx_course_id (course_id), INDEX idx_course_id (course_id),
INDEX idx_stage_name (stage_name), INDEX idx_stage_name (stage_name),
INDEX idx_order_num (order_num), INDEX idx_order_num (order_num),
INDEX idx_is_deleted (is_deleted), INDEX idx_is_deleted (is_deleted),
-- 外键 -- 外键
CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id) CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id)
REFERENCES growth_paths(id) ON DELETE CASCADE, REFERENCES growth_paths(id) ON DELETE CASCADE,
CONSTRAINT fk_gpn_course FOREIGN KEY (course_id) CONSTRAINT fk_gpn_course FOREIGN KEY (course_id)
REFERENCES courses(id) ON DELETE CASCADE REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='成长路径节点表'; COMMENT='成长路径节点表';
-- ===================================================== -- =====================================================
-- 3. 创建用户成长路径进度表 -- 3. 创建用户成长路径进度表
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS user_growth_path_progress ( CREATE TABLE IF NOT EXISTS user_growth_path_progress (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
growth_path_id INT NOT NULL COMMENT '成长路径ID', growth_path_id INT NOT NULL COMMENT '成长路径ID',
current_node_id INT NULL COMMENT '当前学习节点ID', current_node_id INT NULL COMMENT '当前学习节点ID',
completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]', completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]',
total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比', total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比',
status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed', status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed',
-- 时间记录 -- 时间记录
started_at DATETIME NULL COMMENT '开始时间', started_at DATETIME NULL COMMENT '开始时间',
completed_at DATETIME NULL COMMENT '完成时间', completed_at DATETIME NULL COMMENT '完成时间',
last_activity_at DATETIME NULL COMMENT '最后活动时间', last_activity_at DATETIME NULL COMMENT '最后活动时间',
-- 时间戳 -- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
-- 索引 -- 索引
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_growth_path_id (growth_path_id), INDEX idx_growth_path_id (growth_path_id),
INDEX idx_status (status), INDEX idx_status (status),
UNIQUE KEY uk_user_growth_path (user_id, growth_path_id), UNIQUE KEY uk_user_growth_path (user_id, growth_path_id),
-- 外键 -- 外键
CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id) CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE, REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id) CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id)
REFERENCES growth_paths(id) ON DELETE CASCADE REFERENCES growth_paths(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='用户成长路径进度表'; COMMENT='用户成长路径进度表';
-- ===================================================== -- =====================================================
-- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况) -- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况)
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS user_node_completions ( CREATE TABLE IF NOT EXISTS user_node_completions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
growth_path_id INT NOT NULL COMMENT '成长路径ID', growth_path_id INT NOT NULL COMMENT '成长路径ID',
node_id INT NOT NULL COMMENT '节点ID', node_id INT NOT NULL COMMENT '节点ID',
course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度', course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度',
status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed', status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed',
-- 时间记录 -- 时间记录
unlocked_at DATETIME NULL COMMENT '解锁时间', unlocked_at DATETIME NULL COMMENT '解锁时间',
started_at DATETIME NULL COMMENT '开始学习时间', started_at DATETIME NULL COMMENT '开始学习时间',
completed_at DATETIME NULL COMMENT '完成时间', completed_at DATETIME NULL COMMENT '完成时间',
-- 时间戳 -- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
-- 索引 -- 索引
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_growth_path_id (growth_path_id), INDEX idx_growth_path_id (growth_path_id),
INDEX idx_node_id (node_id), INDEX idx_node_id (node_id),
INDEX idx_status (status), INDEX idx_status (status),
UNIQUE KEY uk_user_node (user_id, node_id), UNIQUE KEY uk_user_node (user_id, node_id),
-- 外键 -- 外键
CONSTRAINT fk_unc_user FOREIGN KEY (user_id) CONSTRAINT fk_unc_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE, REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_unc_node FOREIGN KEY (node_id) CONSTRAINT fk_unc_node FOREIGN KEY (node_id)
REFERENCES growth_path_nodes(id) ON DELETE CASCADE REFERENCES growth_path_nodes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='用户节点完成记录表'; COMMENT='用户节点完成记录表';

View File

@@ -1,206 +1,206 @@
""" """
成长路径相关数据库模型 成长路径相关数据库模型
""" """
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import ( from sqlalchemy import (
String, String,
Text, Text,
Integer, Integer,
Boolean, Boolean,
ForeignKey, ForeignKey,
Enum as SQLEnum, Enum as SQLEnum,
JSON, JSON,
DateTime, DateTime,
DECIMAL, DECIMAL,
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel, SoftDeleteMixin from app.models.base import BaseModel, SoftDeleteMixin
class GrowthPathStatus(str, Enum): class GrowthPathStatus(str, Enum):
"""成长路径学习状态""" """成长路径学习状态"""
NOT_STARTED = "not_started" # 未开始 NOT_STARTED = "not_started" # 未开始
IN_PROGRESS = "in_progress" # 进行中 IN_PROGRESS = "in_progress" # 进行中
COMPLETED = "completed" # 已完成 COMPLETED = "completed" # 已完成
class NodeStatus(str, Enum): class NodeStatus(str, Enum):
"""节点状态""" """节点状态"""
LOCKED = "locked" # 锁定(前置未完成) LOCKED = "locked" # 锁定(前置未完成)
UNLOCKED = "unlocked" # 已解锁(可以开始) UNLOCKED = "unlocked" # 已解锁(可以开始)
IN_PROGRESS = "in_progress" # 学习中 IN_PROGRESS = "in_progress" # 学习中
COMPLETED = "completed" # 已完成 COMPLETED = "completed" # 已完成
class GrowthPathNode(BaseModel, SoftDeleteMixin): class GrowthPathNode(BaseModel, SoftDeleteMixin):
""" """
成长路径节点表 成长路径节点表
每个节点对应一门课程 每个节点对应一门课程
""" """
__tablename__ = "growth_path_nodes" __tablename__ = "growth_path_nodes"
# 关联 # 关联
growth_path_id: Mapped[int] = mapped_column( growth_path_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("growth_paths.id", ondelete="CASCADE"), ForeignKey("growth_paths.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="成长路径ID" comment="成长路径ID"
) )
course_id: Mapped[int] = mapped_column( course_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("courses.id", ondelete="CASCADE"), ForeignKey("courses.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="课程ID" comment="课程ID"
) )
# 节点信息 # 节点信息
stage_name: Mapped[Optional[str]] = mapped_column( stage_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, comment="所属阶段名称" String(100), nullable=True, comment="所属阶段名称"
) )
title: Mapped[str] = mapped_column( title: Mapped[str] = mapped_column(
String(200), nullable=False, comment="节点标题" String(200), nullable=False, comment="节点标题"
) )
description: Mapped[Optional[str]] = mapped_column( description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, comment="节点描述" Text, nullable=True, comment="节点描述"
) )
# 配置 # 配置
order_num: Mapped[int] = mapped_column( order_num: Mapped[int] = mapped_column(
Integer, default=0, nullable=False, comment="排序顺序" Integer, default=0, nullable=False, comment="排序顺序"
) )
is_required: Mapped[bool] = mapped_column( is_required: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, comment="是否必修" Boolean, default=True, nullable=False, comment="是否必修"
) )
prerequisites: Mapped[Optional[List[int]]] = mapped_column( prerequisites: Mapped[Optional[List[int]]] = mapped_column(
JSON, nullable=True, comment="前置节点IDs" JSON, nullable=True, comment="前置节点IDs"
) )
estimated_days: Mapped[int] = mapped_column( estimated_days: Mapped[int] = mapped_column(
Integer, default=7, nullable=False, comment="预计学习天数" Integer, default=7, nullable=False, comment="预计学习天数"
) )
# 关联关系 # 关联关系
growth_path: Mapped["GrowthPath"] = relationship( growth_path: Mapped["GrowthPath"] = relationship(
"GrowthPath", back_populates="nodes" "GrowthPath", back_populates="nodes"
) )
course: Mapped["Course"] = relationship("Course") course: Mapped["Course"] = relationship("Course")
user_completions: Mapped[List["UserNodeCompletion"]] = relationship( user_completions: Mapped[List["UserNodeCompletion"]] = relationship(
"UserNodeCompletion", back_populates="node" "UserNodeCompletion", back_populates="node"
) )
class UserGrowthPathProgress(BaseModel): class UserGrowthPathProgress(BaseModel):
""" """
用户成长路径进度表 用户成长路径进度表
记录用户在某条成长路径上的整体进度 记录用户在某条成长路径上的整体进度
""" """
__tablename__ = "user_growth_path_progress" __tablename__ = "user_growth_path_progress"
# 关联 # 关联
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="用户ID" comment="用户ID"
) )
growth_path_id: Mapped[int] = mapped_column( growth_path_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("growth_paths.id", ondelete="CASCADE"), ForeignKey("growth_paths.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="成长路径ID" comment="成长路径ID"
) )
# 进度信息 # 进度信息
current_node_id: Mapped[Optional[int]] = mapped_column( current_node_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, comment="当前学习节点ID" Integer, nullable=True, comment="当前学习节点ID"
) )
completed_node_ids: Mapped[Optional[List[int]]] = mapped_column( completed_node_ids: Mapped[Optional[List[int]]] = mapped_column(
JSON, nullable=True, comment="已完成节点IDs" JSON, nullable=True, comment="已完成节点IDs"
) )
total_progress: Mapped[Decimal] = mapped_column( total_progress: Mapped[Decimal] = mapped_column(
DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比" DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比"
) )
# 状态 # 状态
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(20), String(20),
default=GrowthPathStatus.NOT_STARTED.value, default=GrowthPathStatus.NOT_STARTED.value,
nullable=False, nullable=False,
comment="状态" comment="状态"
) )
# 时间记录 # 时间记录
started_at: Mapped[Optional[datetime]] = mapped_column( started_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="开始时间" DateTime(timezone=True), nullable=True, comment="开始时间"
) )
completed_at: Mapped[Optional[datetime]] = mapped_column( completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="完成时间" DateTime(timezone=True), nullable=True, comment="完成时间"
) )
last_activity_at: Mapped[Optional[datetime]] = mapped_column( last_activity_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="最后活动时间" DateTime(timezone=True), nullable=True, comment="最后活动时间"
) )
# 关联关系 # 关联关系
user: Mapped["User"] = relationship("User") user: Mapped["User"] = relationship("User")
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
class UserNodeCompletion(BaseModel): class UserNodeCompletion(BaseModel):
""" """
用户节点完成记录表 用户节点完成记录表
详细记录用户在每个节点上的学习状态 详细记录用户在每个节点上的学习状态
""" """
__tablename__ = "user_node_completions" __tablename__ = "user_node_completions"
# 关联 # 关联
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="用户ID" comment="用户ID"
) )
growth_path_id: Mapped[int] = mapped_column( growth_path_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("growth_paths.id", ondelete="CASCADE"), ForeignKey("growth_paths.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="成长路径ID" comment="成长路径ID"
) )
node_id: Mapped[int] = mapped_column( node_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("growth_path_nodes.id", ondelete="CASCADE"), ForeignKey("growth_path_nodes.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="节点ID" comment="节点ID"
) )
# 进度信息 # 进度信息
course_progress: Mapped[Decimal] = mapped_column( course_progress: Mapped[Decimal] = mapped_column(
DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度" DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度"
) )
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(20), String(20),
default=NodeStatus.LOCKED.value, default=NodeStatus.LOCKED.value,
nullable=False, nullable=False,
comment="状态" comment="状态"
) )
# 时间记录 # 时间记录
unlocked_at: Mapped[Optional[datetime]] = mapped_column( unlocked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="解锁时间" DateTime(timezone=True), nullable=True, comment="解锁时间"
) )
started_at: Mapped[Optional[datetime]] = mapped_column( started_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="开始学习时间" DateTime(timezone=True), nullable=True, comment="开始学习时间"
) )
completed_at: Mapped[Optional[datetime]] = mapped_column( completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="完成时间" DateTime(timezone=True), nullable=True, comment="完成时间"
) )
# 关联关系 # 关联关系
user: Mapped["User"] = relationship("User") user: Mapped["User"] = relationship("User")
node: Mapped["GrowthPathNode"] = relationship( node: Mapped["GrowthPathNode"] = relationship(
"GrowthPathNode", back_populates="user_completions" "GrowthPathNode", back_populates="user_completions"
) )
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")

View File

@@ -1,226 +1,226 @@
""" """
成长路径相关 Schema 成长路径相关 Schema
""" """
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ===================================================== # =====================================================
# 基础数据结构 # 基础数据结构
# ===================================================== # =====================================================
class StageConfig(BaseModel): class StageConfig(BaseModel):
"""阶段配置""" """阶段配置"""
name: str = Field(..., description="阶段名称") name: str = Field(..., description="阶段名称")
description: Optional[str] = Field(None, description="阶段描述") description: Optional[str] = Field(None, description="阶段描述")
order: int = Field(0, description="排序") order: int = Field(0, description="排序")
class NodeBase(BaseModel): class NodeBase(BaseModel):
"""节点基础信息""" """节点基础信息"""
course_id: int = Field(..., description="课程ID") course_id: int = Field(..., description="课程ID")
stage_name: Optional[str] = Field(None, description="所属阶段名称") stage_name: Optional[str] = Field(None, description="所属阶段名称")
title: str = Field(..., description="节点标题") title: str = Field(..., description="节点标题")
description: Optional[str] = Field(None, description="节点描述") description: Optional[str] = Field(None, description="节点描述")
order_num: int = Field(0, description="排序顺序") order_num: int = Field(0, description="排序顺序")
is_required: bool = Field(True, description="是否必修") is_required: bool = Field(True, description="是否必修")
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs") prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
estimated_days: int = Field(7, description="预计学习天数") estimated_days: int = Field(7, description="预计学习天数")
# ===================================================== # =====================================================
# 管理端 - 创建/更新 # 管理端 - 创建/更新
# ===================================================== # =====================================================
class GrowthPathNodeCreate(NodeBase): class GrowthPathNodeCreate(NodeBase):
"""创建节点""" """创建节点"""
pass pass
class GrowthPathNodeUpdate(BaseModel): class GrowthPathNodeUpdate(BaseModel):
"""更新节点""" """更新节点"""
course_id: Optional[int] = None course_id: Optional[int] = None
stage_name: Optional[str] = None stage_name: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
order_num: Optional[int] = None order_num: Optional[int] = None
is_required: Optional[bool] = None is_required: Optional[bool] = None
prerequisites: Optional[List[int]] = None prerequisites: Optional[List[int]] = None
estimated_days: Optional[int] = None estimated_days: Optional[int] = None
class GrowthPathCreate(BaseModel): class GrowthPathCreate(BaseModel):
"""创建成长路径""" """创建成长路径"""
name: str = Field(..., description="路径名称") name: str = Field(..., description="路径名称")
description: Optional[str] = Field(None, description="路径描述") description: Optional[str] = Field(None, description="路径描述")
target_role: Optional[str] = Field(None, description="目标角色") target_role: Optional[str] = Field(None, description="目标角色")
position_id: Optional[int] = Field(None, description="关联岗位ID兼容旧版") position_id: Optional[int] = Field(None, description="关联岗位ID兼容旧版")
position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表支持多选") position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表支持多选")
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置") stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数") estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
is_active: bool = Field(True, description="是否启用") is_active: bool = Field(True, description="是否启用")
sort_order: int = Field(0, description="排序") sort_order: int = Field(0, description="排序")
nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表") nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表")
class GrowthPathUpdate(BaseModel): class GrowthPathUpdate(BaseModel):
"""更新成长路径""" """更新成长路径"""
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
target_role: Optional[str] = None target_role: Optional[str] = None
position_id: Optional[int] = None position_id: Optional[int] = None
position_ids: Optional[List[int]] = None position_ids: Optional[List[int]] = None
stages: Optional[List[StageConfig]] = None stages: Optional[List[StageConfig]] = None
estimated_duration_days: Optional[int] = None estimated_duration_days: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点 nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点
# ===================================================== # =====================================================
# 管理端 - 响应 # 管理端 - 响应
# ===================================================== # =====================================================
class GrowthPathNodeResponse(NodeBase): class GrowthPathNodeResponse(NodeBase):
"""节点响应""" """节点响应"""
id: int id: int
growth_path_id: int growth_path_id: int
course_name: Optional[str] = None # 课程名称(关联查询) course_name: Optional[str] = None # 课程名称(关联查询)
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
class GrowthPathResponse(BaseModel): class GrowthPathResponse(BaseModel):
"""成长路径响应(管理端)""" """成长路径响应(管理端)"""
id: int id: int
name: str name: str
description: Optional[str] = None description: Optional[str] = None
target_role: Optional[str] = None target_role: Optional[str] = None
position_id: Optional[int] = None position_id: Optional[int] = None
position_name: Optional[str] = None # 岗位名称(关联查询) position_name: Optional[str] = None # 岗位名称(关联查询)
stages: Optional[List[StageConfig]] = None stages: Optional[List[StageConfig]] = None
estimated_duration_days: Optional[int] = None estimated_duration_days: Optional[int] = None
is_active: bool is_active: bool
sort_order: int sort_order: int
nodes: List[GrowthPathNodeResponse] = [] nodes: List[GrowthPathNodeResponse] = []
node_count: int = 0 # 节点数量 node_count: int = 0 # 节点数量
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
class GrowthPathListResponse(BaseModel): class GrowthPathListResponse(BaseModel):
"""成长路径列表响应""" """成长路径列表响应"""
id: int id: int
name: str name: str
description: Optional[str] = None description: Optional[str] = None
position_id: Optional[int] = None position_id: Optional[int] = None
position_name: Optional[str] = None position_name: Optional[str] = None
is_active: bool is_active: bool
node_count: int = 0 node_count: int = 0
estimated_duration_days: Optional[int] = None estimated_duration_days: Optional[int] = None
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
# ===================================================== # =====================================================
# 学员端 - 响应 # 学员端 - 响应
# ===================================================== # =====================================================
class TraineeNodeResponse(BaseModel): class TraineeNodeResponse(BaseModel):
"""学员端节点响应(含进度状态)""" """学员端节点响应(含进度状态)"""
id: int id: int
course_id: int course_id: int
title: str title: str
description: Optional[str] = None description: Optional[str] = None
stage_name: Optional[str] = None stage_name: Optional[str] = None
is_required: bool is_required: bool
estimated_days: int estimated_days: int
order_num: int order_num: int
# 学员特有 # 学员特有
status: str = Field(..., description="状态: locked/unlocked/in_progress/completed") status: str = Field(..., description="状态: locked/unlocked/in_progress/completed")
progress: float = Field(0, description="课程学习进度 0-100") progress: float = Field(0, description="课程学习进度 0-100")
# 课程信息 # 课程信息
course_name: Optional[str] = None course_name: Optional[str] = None
course_cover: Optional[str] = None course_cover: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
class TraineeStageResponse(BaseModel): class TraineeStageResponse(BaseModel):
"""学员端阶段响应""" """学员端阶段响应"""
name: str name: str
description: Optional[str] = None description: Optional[str] = None
completed: int = Field(0, description="已完成节点数") completed: int = Field(0, description="已完成节点数")
total: int = Field(0, description="总节点数") total: int = Field(0, description="总节点数")
nodes: List[TraineeNodeResponse] = [] nodes: List[TraineeNodeResponse] = []
class TraineeGrowthPathResponse(BaseModel): class TraineeGrowthPathResponse(BaseModel):
"""学员端成长路径响应""" """学员端成长路径响应"""
id: int id: int
name: str name: str
description: Optional[str] = None description: Optional[str] = None
position_id: Optional[int] = None position_id: Optional[int] = None
position_name: Optional[str] = None position_name: Optional[str] = None
# 进度信息 # 进度信息
total_progress: float = Field(0, description="总进度百分比") total_progress: float = Field(0, description="总进度百分比")
completed_nodes: int = Field(0, description="已完成节点数") completed_nodes: int = Field(0, description="已完成节点数")
total_nodes: int = Field(0, description="总节点数") total_nodes: int = Field(0, description="总节点数")
status: str = Field("not_started", description="状态: not_started/in_progress/completed") status: str = Field("not_started", description="状态: not_started/in_progress/completed")
# 时间信息 # 时间信息
started_at: Optional[datetime] = None started_at: Optional[datetime] = None
estimated_completion_days: Optional[int] = None estimated_completion_days: Optional[int] = None
# 阶段和节点 # 阶段和节点
stages: List[TraineeStageResponse] = [] stages: List[TraineeStageResponse] = []
class Config: class Config:
from_attributes = True from_attributes = True
# ===================================================== # =====================================================
# 用户进度 # 用户进度
# ===================================================== # =====================================================
class UserGrowthPathProgressResponse(BaseModel): class UserGrowthPathProgressResponse(BaseModel):
"""用户成长路径进度响应""" """用户成长路径进度响应"""
id: int id: int
user_id: int user_id: int
growth_path_id: int growth_path_id: int
growth_path_name: str growth_path_name: str
current_node_id: Optional[int] = None current_node_id: Optional[int] = None
current_node_title: Optional[str] = None current_node_title: Optional[str] = None
completed_node_ids: List[int] = [] completed_node_ids: List[int] = []
total_progress: float total_progress: float
status: str status: str
started_at: Optional[datetime] = None started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
last_activity_at: Optional[datetime] = None last_activity_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True
class StartGrowthPathRequest(BaseModel): class StartGrowthPathRequest(BaseModel):
"""开始学习成长路径请求""" """开始学习成长路径请求"""
growth_path_id: int = Field(..., description="成长路径ID") growth_path_id: int = Field(..., description="成长路径ID")
class CompleteNodeRequest(BaseModel): class CompleteNodeRequest(BaseModel):
"""完成节点请求""" """完成节点请求"""
node_id: int = Field(..., description="节点ID") node_id: int = Field(..., description="节点ID")

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
""" """
工具模块 工具模块
""" """
from app.utils.score_distributor import ( from app.utils.score_distributor import (
ScoreDistributor, ScoreDistributor,
distribute_scores, distribute_scores,
get_question_score, get_question_score,
) )
__all__ = [ __all__ = [
"ScoreDistributor", "ScoreDistributor",
"distribute_scores", "distribute_scores",
"get_question_score", "get_question_score",
] ]

View File

@@ -1,218 +1,218 @@
""" """
分数分配工具 分数分配工具
解决题目分数无法整除的问题,确保: 解决题目分数无法整除的问题,确保:
1. 所有题目分数之和精确等于总分 1. 所有题目分数之和精确等于总分
2. 题目分数差异最小化最多相差1分 2. 题目分数差异最小化最多相差1分
3. 支持整数分配和小数分配两种模式 3. 支持整数分配和小数分配两种模式
""" """
from typing import List, Tuple from typing import List, Tuple
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
import math import math
class ScoreDistributor: class ScoreDistributor:
""" """
智能分数分配器 智能分数分配器
使用示例: 使用示例:
distributor = ScoreDistributor(total_score=100, question_count=6) distributor = ScoreDistributor(total_score=100, question_count=6)
scores = distributor.distribute() scores = distributor.distribute()
# 结果: [17, 17, 17, 17, 16, 16] 总和=100 # 结果: [17, 17, 17, 17, 16, 16] 总和=100
""" """
def __init__(self, total_score: float, question_count: int): def __init__(self, total_score: float, question_count: int):
""" """
初始化分配器 初始化分配器
Args: Args:
total_score: 总分(如 100 total_score: 总分(如 100
question_count: 题目数量(如 6 question_count: 题目数量(如 6
""" """
if question_count <= 0: if question_count <= 0:
raise ValueError("题目数量必须大于0") raise ValueError("题目数量必须大于0")
if total_score <= 0: if total_score <= 0:
raise ValueError("总分必须大于0") raise ValueError("总分必须大于0")
self.total_score = total_score self.total_score = total_score
self.question_count = question_count self.question_count = question_count
def distribute_integer(self) -> List[int]: def distribute_integer(self) -> List[int]:
""" """
整数分配模式 整数分配模式
将总分分配为整数前面的题目分数可能比后面的多1分 将总分分配为整数前面的题目分数可能比后面的多1分
Returns: Returns:
分数列表,如 [17, 17, 17, 17, 16, 16] 分数列表,如 [17, 17, 17, 17, 16, 16]
示例: 示例:
100分 / 6题 = [17, 17, 17, 17, 16, 16] 100分 / 6题 = [17, 17, 17, 17, 16, 16]
100分 / 7题 = [15, 15, 14, 14, 14, 14, 14] 100分 / 7题 = [15, 15, 14, 14, 14, 14, 14]
""" """
total = int(self.total_score) total = int(self.total_score)
count = self.question_count count = self.question_count
# 基础分数(向下取整) # 基础分数(向下取整)
base_score = total // count base_score = total // count
# 需要额外加1分的题目数量 # 需要额外加1分的题目数量
extra_count = total % count extra_count = total % count
# 生成分数列表 # 生成分数列表
scores = [] scores = []
for i in range(count): for i in range(count):
if i < extra_count: if i < extra_count:
scores.append(base_score + 1) scores.append(base_score + 1)
else: else:
scores.append(base_score) scores.append(base_score)
return scores return scores
def distribute_decimal(self, decimal_places: int = 1) -> List[float]: def distribute_decimal(self, decimal_places: int = 1) -> List[float]:
""" """
小数分配模式 小数分配模式
将总分分配为小数,最后一题用于补齐差额 将总分分配为小数,最后一题用于补齐差额
Args: Args:
decimal_places: 小数位数默认1位 decimal_places: 小数位数默认1位
Returns: Returns:
分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5] 分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5]
""" """
count = self.question_count count = self.question_count
# 计算每题分数并四舍五入 # 计算每题分数并四舍五入
per_score = self.total_score / count per_score = self.total_score / count
rounded_score = round(per_score, decimal_places) rounded_score = round(per_score, decimal_places)
# 前 n-1 题使用四舍五入的分数 # 前 n-1 题使用四舍五入的分数
scores = [rounded_score] * (count - 1) scores = [rounded_score] * (count - 1)
# 最后一题用总分减去前面的和,确保总分精确 # 最后一题用总分减去前面的和,确保总分精确
last_score = round(self.total_score - sum(scores), decimal_places) last_score = round(self.total_score - sum(scores), decimal_places)
scores.append(last_score) scores.append(last_score)
return scores return scores
def distribute(self, mode: str = "integer") -> List[float]: def distribute(self, mode: str = "integer") -> List[float]:
""" """
分配分数 分配分数
Args: Args:
mode: 分配模式 mode: 分配模式
- "integer": 整数分配(推荐) - "integer": 整数分配(推荐)
- "decimal": 小数分配 - "decimal": 小数分配
- "decimal_1": 保留1位小数 - "decimal_1": 保留1位小数
- "decimal_2": 保留2位小数 - "decimal_2": 保留2位小数
Returns: Returns:
分数列表 分数列表
""" """
if mode == "integer": if mode == "integer":
return [float(s) for s in self.distribute_integer()] return [float(s) for s in self.distribute_integer()]
elif mode == "decimal" or mode == "decimal_1": elif mode == "decimal" or mode == "decimal_1":
return self.distribute_decimal(1) return self.distribute_decimal(1)
elif mode == "decimal_2": elif mode == "decimal_2":
return self.distribute_decimal(2) return self.distribute_decimal(2)
else: else:
return [float(s) for s in self.distribute_integer()] return [float(s) for s in self.distribute_integer()]
def get_score_for_question(self, question_index: int, mode: str = "integer") -> float: def get_score_for_question(self, question_index: int, mode: str = "integer") -> float:
""" """
获取指定题目的分数 获取指定题目的分数
Args: Args:
question_index: 题目索引从0开始 question_index: 题目索引从0开始
mode: 分配模式 mode: 分配模式
Returns: Returns:
该题目的分数 该题目的分数
""" """
scores = self.distribute(mode) scores = self.distribute(mode)
if 0 <= question_index < len(scores): if 0 <= question_index < len(scores):
return scores[question_index] return scores[question_index]
raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})") raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})")
def validate(self) -> Tuple[bool, str]: def validate(self) -> Tuple[bool, str]:
""" """
验证分配结果 验证分配结果
Returns: Returns:
(是否有效, 信息) (是否有效, 信息)
""" """
scores = self.distribute() scores = self.distribute()
total = sum(scores) total = sum(scores)
if abs(total - self.total_score) < 0.01: if abs(total - self.total_score) < 0.01:
return True, f"分配有效:{scores},总分={total}" return True, f"分配有效:{scores},总分={total}"
else: else:
return False, f"分配无效:{scores},总分={total},期望={self.total_score}" return False, f"分配无效:{scores},总分={total},期望={self.total_score}"
@staticmethod @staticmethod
def format_score(score: float, decimal_places: int = 1) -> str: def format_score(score: float, decimal_places: int = 1) -> str:
""" """
格式化分数显示 格式化分数显示
Args: Args:
score: 分数 score: 分数
decimal_places: 小数位数 decimal_places: 小数位数
Returns: Returns:
格式化的分数字符串 格式化的分数字符串
""" """
if score == int(score): if score == int(score):
return str(int(score)) return str(int(score))
return f"{score:.{decimal_places}f}" return f"{score:.{decimal_places}f}"
@staticmethod @staticmethod
def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float: def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float:
""" """
计算及格分数 计算及格分数
Args: Args:
total_score: 总分 total_score: 总分
pass_rate: 及格率默认60% pass_rate: 及格率默认60%
Returns: Returns:
及格分数(整数) 及格分数(整数)
""" """
return math.ceil(total_score * pass_rate) return math.ceil(total_score * pass_rate)
def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]: def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]:
""" """
便捷函数:分配分数 便捷函数:分配分数
Args: Args:
total_score: 总分 total_score: 总分
question_count: 题目数量 question_count: 题目数量
mode: 分配模式integer/decimal mode: 分配模式integer/decimal
Returns: Returns:
分数列表 分数列表
""" """
distributor = ScoreDistributor(total_score, question_count) distributor = ScoreDistributor(total_score, question_count)
return distributor.distribute(mode) return distributor.distribute(mode)
def get_question_score( def get_question_score(
total_score: float, total_score: float,
question_count: int, question_count: int,
question_index: int, question_index: int,
mode: str = "integer" mode: str = "integer"
) -> float: ) -> float:
""" """
便捷函数:获取指定题目的分数 便捷函数:获取指定题目的分数
Args: Args:
total_score: 总分 total_score: 总分
question_count: 题目数量 question_count: 题目数量
question_index: 题目索引从0开始 question_index: 题目索引从0开始
mode: 分配模式 mode: 分配模式
Returns: Returns:
该题目的分数 该题目的分数
""" """
distributor = ScoreDistributor(total_score, question_count) distributor = ScoreDistributor(total_score, question_count)
return distributor.get_score_for_question(question_index, mode) return distributor.get_score_for_question(question_index, mode)

View File

@@ -1,201 +1,201 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
修复历史考试的小数分数问题 修复历史考试的小数分数问题
将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数 将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数
使用智能整数分配算法,确保所有题目分数之和等于总分 使用智能整数分配算法,确保所有题目分数之和等于总分
使用方法: 使用方法:
# 在后端容器中执行 # 在后端容器中执行
cd /app cd /app
python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改 python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改
python scripts/fix_exam_scores.py # 实际执行修复 python scripts/fix_exam_scores.py # 实际执行修复
""" """
import sys import sys
import json import json
import argparse import argparse
from decimal import Decimal from decimal import Decimal
# 添加项目路径 # 添加项目路径
sys.path.insert(0, '/app') sys.path.insert(0, '/app')
def distribute_integer_scores(total_score: float, question_count: int) -> list: def distribute_integer_scores(total_score: float, question_count: int) -> list:
""" """
整数分配分数 整数分配分数
将总分分配为整数前面的题目分数可能比后面的多1分 将总分分配为整数前面的题目分数可能比后面的多1分
示例: 示例:
100分 / 6题 = [17, 17, 17, 17, 16, 16] 100分 / 6题 = [17, 17, 17, 17, 16, 16]
100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5] 100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5]
""" """
total = int(total_score) total = int(total_score)
count = question_count count = question_count
# 基础分数(向下取整) # 基础分数(向下取整)
base_score = total // count base_score = total // count
# 需要额外加1分的题目数量 # 需要额外加1分的题目数量
extra_count = total % count extra_count = total % count
# 生成分数列表 # 生成分数列表
scores = [] scores = []
for i in range(count): for i in range(count):
if i < extra_count: if i < extra_count:
scores.append(base_score + 1) scores.append(base_score + 1)
else: else:
scores.append(base_score) scores.append(base_score)
return scores return scores
def is_decimal_score(score) -> bool: def is_decimal_score(score) -> bool:
"""检查分数是否是小数(非整数)""" """检查分数是否是小数(非整数)"""
if score is None: if score is None:
return False return False
try: try:
score_float = float(score) score_float = float(score)
return score_float != int(score_float) return score_float != int(score_float)
except (ValueError, TypeError): except (ValueError, TypeError):
return False return False
def fix_exam_scores(dry_run: bool = True, db_url: str = None): def fix_exam_scores(dry_run: bool = True, db_url: str = None):
""" """
修复考试分数 修复考试分数
Args: Args:
dry_run: 如果为 True只预览不实际修改 dry_run: 如果为 True只预览不实际修改
db_url: 数据库连接字符串,如果为 None 则从环境变量读取 db_url: 数据库连接字符串,如果为 None 则从环境变量读取
""" """
import os import os
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
# 获取数据库连接 # 获取数据库连接
if db_url is None: if db_url is None:
db_url = os.environ.get('DATABASE_URL') db_url = os.environ.get('DATABASE_URL')
if not db_url: if not db_url:
# 尝试从配置文件读取 # 尝试从配置文件读取
try: try:
from app.core.config import settings from app.core.config import settings
db_url = settings.DATABASE_URL db_url = settings.DATABASE_URL
except: except:
print("错误:无法获取数据库连接字符串") print("错误:无法获取数据库连接字符串")
print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行") print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行")
sys.exit(1) sys.exit(1)
# 将 mysql+aiomysql:// 转换为 mysql+pymysql:// # 将 mysql+aiomysql:// 转换为 mysql+pymysql://
if 'aiomysql' in db_url: if 'aiomysql' in db_url:
db_url = db_url.replace('aiomysql', 'pymysql') db_url = db_url.replace('aiomysql', 'pymysql')
print(f"连接数据库...") print(f"连接数据库...")
engine = create_engine(db_url, echo=False) engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
session = Session() session = Session()
try: try:
# 查询所有考试记录 # 查询所有考试记录
result = session.execute(text(""" result = session.execute(text("""
SELECT id, user_id, course_id, exam_name, question_count, total_score, questions SELECT id, user_id, course_id, exam_name, question_count, total_score, questions
FROM exams FROM exams
WHERE questions IS NOT NULL WHERE questions IS NOT NULL
ORDER BY id DESC ORDER BY id DESC
""")) """))
exams = result.fetchall() exams = result.fetchall()
print(f"找到 {len(exams)} 条考试记录") print(f"找到 {len(exams)} 条考试记录")
fixed_count = 0 fixed_count = 0
skipped_count = 0 skipped_count = 0
error_count = 0 error_count = 0
for exam in exams: for exam in exams:
exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam
try: try:
# 解析 questions JSON # 解析 questions JSON
if isinstance(questions_json, str): if isinstance(questions_json, str):
questions = json.loads(questions_json) questions = json.loads(questions_json)
else: else:
questions = questions_json questions = questions_json
if not questions or not isinstance(questions, list): if not questions or not isinstance(questions, list):
skipped_count += 1 skipped_count += 1
continue continue
# 检查是否有小数分数 # 检查是否有小数分数
has_decimal = False has_decimal = False
for q in questions: for q in questions:
if 'score' in q and is_decimal_score(q['score']): if 'score' in q and is_decimal_score(q['score']):
has_decimal = True has_decimal = True
break break
if not has_decimal: if not has_decimal:
skipped_count += 1 skipped_count += 1
continue continue
# 计算新的整数分数 # 计算新的整数分数
actual_count = len(questions) actual_count = len(questions)
actual_total = total_score or 100 actual_total = total_score or 100
new_scores = distribute_integer_scores(actual_total, actual_count) new_scores = distribute_integer_scores(actual_total, actual_count)
# 更新每道题的分数 # 更新每道题的分数
old_scores = [q.get('score', 0) for q in questions] old_scores = [q.get('score', 0) for q in questions]
for i, q in enumerate(questions): for i, q in enumerate(questions):
q['score'] = new_scores[i] q['score'] = new_scores[i]
# 验证总分 # 验证总分
new_total = sum(new_scores) new_total = sum(new_scores)
if abs(new_total - actual_total) > 0.01: if abs(new_total - actual_total) > 0.01:
print(f" 警告exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}") print(f" 警告exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}")
error_count += 1 error_count += 1
continue continue
if dry_run: if dry_run:
print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}") 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" 旧分数: {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}") print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}")
else: else:
# 实际更新数据库 # 实际更新数据库
new_json = json.dumps(questions, ensure_ascii=False) new_json = json.dumps(questions, ensure_ascii=False)
session.execute(text(""" session.execute(text("""
UPDATE exams SET questions = :questions WHERE id = :exam_id UPDATE exams SET questions = :questions WHERE id = :exam_id
"""), {"questions": new_json, "exam_id": exam_id}) """), {"questions": new_json, "exam_id": exam_id})
print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}") print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}")
fixed_count += 1 fixed_count += 1
except Exception as e: except Exception as e:
print(f" 错误:处理 exam_id={exam_id} 时出错: {e}") print(f" 错误:处理 exam_id={exam_id} 时出错: {e}")
error_count += 1 error_count += 1
continue continue
if not dry_run: if not dry_run:
session.commit() session.commit()
print("\n已提交数据库更改") print("\n已提交数据库更改")
print(f"\n=== 统计 ===") print(f"\n=== 统计 ===")
print(f"需要修复: {fixed_count}") print(f"需要修复: {fixed_count}")
print(f"已跳过(无小数): {skipped_count}") print(f"已跳过(无小数): {skipped_count}")
print(f"错误: {error_count}") print(f"错误: {error_count}")
if dry_run: if dry_run:
print("\n这是预览模式,未实际修改数据库。") print("\n这是预览模式,未实际修改数据库。")
print("如需实际执行,请去掉 --dry-run 参数重新运行。") print("如需实际执行,请去掉 --dry-run 参数重新运行。")
except Exception as e: except Exception as e:
print(f"执行失败: {e}") print(f"执行失败: {e}")
session.rollback() session.rollback()
raise raise
finally: finally:
session.close() session.close()
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题") parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题")
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库") parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库")
parser.add_argument("--db-url", type=str, help="数据库连接字符串") parser.add_argument("--db-url", type=str, help="数据库连接字符串")
args = parser.parse_args() args = parser.parse_args()
fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url) fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url)

View File

@@ -1,154 +1,154 @@
/** /**
* 分数格式化工具 * 分数格式化工具
* *
* 用于在前端显示分数时进行格式化,避免显示过长的小数 * 用于在前端显示分数时进行格式化,避免显示过长的小数
*/ */
/** /**
* 格式化分数显示 * 格式化分数显示
* *
* @param score 分数 * @param score 分数
* @param decimalPlaces 小数位数默认1位 * @param decimalPlaces 小数位数默认1位
* @returns 格式化后的分数字符串 * @returns 格式化后的分数字符串
* *
* @example * @example
* formatScore(16.666666) // "16.7" * formatScore(16.666666) // "16.7"
* formatScore(17) // "17" * formatScore(17) // "17"
* formatScore(16.5, 0) // "17" * formatScore(16.5, 0) // "17"
*/ */
export function formatScore(score: number, decimalPlaces: number = 1): string { export function formatScore(score: number, decimalPlaces: number = 1): string {
// 如果是整数,直接返回 // 如果是整数,直接返回
if (Number.isInteger(score)) { if (Number.isInteger(score)) {
return score.toString() return score.toString()
} }
// 四舍五入到指定小数位 // 四舍五入到指定小数位
const rounded = Number(score.toFixed(decimalPlaces)) const rounded = Number(score.toFixed(decimalPlaces))
// 如果四舍五入后是整数,去掉小数点 // 如果四舍五入后是整数,去掉小数点
if (Number.isInteger(rounded)) { if (Number.isInteger(rounded)) {
return rounded.toString() return rounded.toString()
} }
return rounded.toFixed(decimalPlaces) return rounded.toFixed(decimalPlaces)
} }
/** /**
* 格式化分数显示(带单位) * 格式化分数显示(带单位)
* *
* @param score 分数 * @param score 分数
* @param unit 单位,默认"分" * @param unit 单位,默认"分"
* @returns 格式化后的分数字符串 * @returns 格式化后的分数字符串
* *
* @example * @example
* formatScoreWithUnit(16.7) // "16.7分" * formatScoreWithUnit(16.7) // "16.7分"
* formatScoreWithUnit(100) // "100分" * formatScoreWithUnit(100) // "100分"
*/ */
export function formatScoreWithUnit(score: number, unit: string = '分'): string { export function formatScoreWithUnit(score: number, unit: string = '分'): string {
return `${formatScore(score)}${unit}` return `${formatScore(score)}${unit}`
} }
/** /**
* 格式化百分比 * 格式化百分比
* *
* @param value 值0-1 或 0-100 * @param value 值0-1 或 0-100
* @param isPercent 是否已经是百分比形式0-100默认false * @param isPercent 是否已经是百分比形式0-100默认false
* @returns 格式化后的百分比字符串 * @returns 格式化后的百分比字符串
* *
* @example * @example
* formatPercent(0.8567) // "85.7%" * formatPercent(0.8567) // "85.7%"
* formatPercent(85.67, true) // "85.7%" * formatPercent(85.67, true) // "85.7%"
*/ */
export function formatPercent(value: number, isPercent: boolean = false): string { export function formatPercent(value: number, isPercent: boolean = false): string {
const percent = isPercent ? value : value * 100 const percent = isPercent ? value : value * 100
return `${formatScore(percent)}%` return `${formatScore(percent)}%`
} }
/** /**
* 计算及格分数 * 计算及格分数
* *
* @param totalScore 总分 * @param totalScore 总分
* @param passRate 及格率默认0.6 * @param passRate 及格率默认0.6
* @returns 及格分数(向上取整) * @returns 及格分数(向上取整)
*/ */
export function calculatePassScore(totalScore: number, passRate: number = 0.6): number { export function calculatePassScore(totalScore: number, passRate: number = 0.6): number {
return Math.ceil(totalScore * passRate) return Math.ceil(totalScore * passRate)
} }
/** /**
* 判断是否及格 * 判断是否及格
* *
* @param score 得分 * @param score 得分
* @param passScore 及格分数 * @param passScore 及格分数
* @returns 是否及格 * @returns 是否及格
*/ */
export function isPassed(score: number, passScore: number): boolean { export function isPassed(score: number, passScore: number): boolean {
return score >= passScore return score >= passScore
} }
/** /**
* 获取分数等级 * 获取分数等级
* *
* @param score 得分 * @param score 得分
* @param totalScore 总分 * @param totalScore 总分
* @returns 等级: 'excellent' | 'good' | 'pass' | 'fail' * @returns 等级: 'excellent' | 'good' | 'pass' | 'fail'
*/ */
export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' { export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' {
const ratio = score / totalScore const ratio = score / totalScore
if (ratio >= 0.9) return 'excellent' if (ratio >= 0.9) return 'excellent'
if (ratio >= 0.75) return 'good' if (ratio >= 0.75) return 'good'
if (ratio >= 0.6) return 'pass' if (ratio >= 0.6) return 'pass'
return 'fail' return 'fail'
} }
/** /**
* 获取分数等级对应的颜色 * 获取分数等级对应的颜色
* *
* @param level 等级 * @param level 等级
* @returns 颜色值 * @returns 颜色值
*/ */
export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string { export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string {
const colors = { const colors = {
excellent: '#67c23a', // 绿色 excellent: '#67c23a', // 绿色
good: '#409eff', // 蓝色 good: '#409eff', // 蓝色
pass: '#e6a23c', // 橙色 pass: '#e6a23c', // 橙色
fail: '#f56c6c', // 红色 fail: '#f56c6c', // 红色
} }
return colors[level] return colors[level]
} }
/** /**
* 智能分配分数(前端预览用) * 智能分配分数(前端预览用)
* *
* @param totalScore 总分 * @param totalScore 总分
* @param questionCount 题目数量 * @param questionCount 题目数量
* @returns 分数数组 * @returns 分数数组
* *
* @example * @example
* distributeScores(100, 6) // [17, 17, 17, 17, 16, 16] * distributeScores(100, 6) // [17, 17, 17, 17, 16, 16]
*/ */
export function distributeScores(totalScore: number, questionCount: number): number[] { export function distributeScores(totalScore: number, questionCount: number): number[] {
if (questionCount <= 0) return [] if (questionCount <= 0) return []
const baseScore = Math.floor(totalScore / questionCount) const baseScore = Math.floor(totalScore / questionCount)
const extraCount = totalScore % questionCount const extraCount = totalScore % questionCount
const scores: number[] = [] const scores: number[] = []
for (let i = 0; i < questionCount; i++) { for (let i = 0; i < questionCount; i++) {
scores.push(i < extraCount ? baseScore + 1 : baseScore) scores.push(i < extraCount ? baseScore + 1 : baseScore)
} }
return scores return scores
} }
export default { export default {
formatScore, formatScore,
formatScoreWithUnit, formatScoreWithUnit,
formatPercent, formatPercent,
calculatePassScore, calculatePassScore,
isPassed, isPassed,
getScoreLevel, getScoreLevel,
getScoreLevelColor, getScoreLevelColor,
distributeScores, distributeScores,
} }

View File

@@ -2,9 +2,9 @@
<div class="growth-path-management-container"> <div class="growth-path-management-container">
<!-- 路径列表视图 --> <!-- 路径列表视图 -->
<template v-if="!editingPath"> <template v-if="!editingPath">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">成长路径管理</h1> <h1 class="page-title">成长路径管理</h1>
<div class="header-actions"> <div class="header-actions">
<el-select <el-select
v-model="filters.position_id" v-model="filters.position_id"
placeholder="筛选岗位" placeholder="筛选岗位"
@@ -19,7 +19,7 @@
:label="pos.name" :label="pos.name"
:value="pos.id" :value="pos.id"
/> />
</el-select> </el-select>
<el-select <el-select
v-model="filters.is_active" v-model="filters.is_active"
placeholder="状态" placeholder="状态"
@@ -34,9 +34,9 @@
<el-button type="primary" @click="handleCreatePath"> <el-button type="primary" @click="handleCreatePath">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
新建路径 新建路径
</el-button> </el-button>
</div>
</div> </div>
</div>
<!-- 路径列表 --> <!-- 路径列表 -->
<div class="path-list-card"> <div class="path-list-card">
@@ -157,7 +157,7 @@
multiple multiple
collapse-tags collapse-tags
collapse-tags-tooltip collapse-tags-tooltip
clearable clearable
style="width: 100%" style="width: 100%"
> >
<el-option <el-option
@@ -170,7 +170,7 @@
<el-button <el-button
link link
type="primary" type="primary"
size="small" size="small"
@click="handleSelectAllPositions" @click="handleSelectAllPositions"
class="select-all-btn" class="select-all-btn"
> >
@@ -228,11 +228,11 @@
placeholder="阶段名称" placeholder="阶段名称"
size="small" size="small"
style="width: 100px" style="width: 100px"
> >
<template #prefix> <template #prefix>
<span class="stage-order">{{ index + 1 }}</span> <span class="stage-order">{{ index + 1 }}</span>
</template> </template>
</el-input> </el-input>
<el-button <el-button
link link
type="danger" type="danger"
@@ -244,8 +244,8 @@
</el-button> </el-button>
</div> </div>
</div> </div>
</div> </div>
<!-- 路径统计 --> <!-- 路径统计 -->
<div class="top-section stats-section"> <div class="top-section stats-section">
<h3>路径统计</h3> <h3>路径统计</h3>
@@ -253,7 +253,7 @@
<div class="stat-box"> <div class="stat-box">
<span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span> <span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span>
<span class="stat-label">课程总数</span> <span class="stat-label">课程总数</span>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<span class="stat-value">{{ requiredCount }}</span> <span class="stat-value">{{ requiredCount }}</span>
<span class="stat-label">必修课程</span> <span class="stat-label">必修课程</span>
@@ -261,10 +261,10 @@
<div class="stat-box"> <div class="stat-box">
<span class="stat-value">{{ totalDuration }}</span> <span class="stat-value">{{ totalDuration }}</span>
<span class="stat-label">总学时(h)</span> <span class="stat-label">总学时(h)</span>
</div>
</div>
</div>
</div> </div>
</div>
</div>
</div>
<!-- 下方左右分栏 --> <!-- 下方左右分栏 -->
<div class="editor-bottom"> <div class="editor-bottom">
@@ -303,11 +303,11 @@
<div class="course-list" v-loading="coursesLoading"> <div class="course-list" v-loading="coursesLoading">
<div <div
v-for="course in filteredCourses" v-for="course in filteredCourses"
:key="course.id" :key="course.id"
class="course-item" class="course-item"
:class="{ 'is-added': isNodeAdded(course.id) }" :class="{ 'is-added': isNodeAdded(course.id) }"
draggable="true" draggable="true"
@dragstart="handleDragStart($event, course)" @dragstart="handleDragStart($event, course)"
@click="handleAddCourse(course)" @click="handleAddCourse(course)"
> >
<div class="course-info"> <div class="course-info">
@@ -320,21 +320,21 @@
<el-icon v-else><Plus /></el-icon> <el-icon v-else><Plus /></el-icon>
</div> </div>
<el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" /> <el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" />
</div> </div>
</div> </div>
<!-- 右侧 2/3已选课程配置 --> <!-- 右侧 2/3已选课程配置 -->
<div class="selected-courses-panel"> <div class="selected-courses-panel">
<div class="panel-header"> <div class="panel-header">
<span>已选课程配置</span> <span>已选课程配置</span>
<el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} </el-tag> <el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} </el-tag>
</div> </div>
<div <div
class="selected-content" class="selected-content"
:class="{ 'is-dragging-over': isDraggingOver }" :class="{ 'is-dragging-over': isDraggingOver }"
@dragover.prevent="handleDragOver" @dragover.prevent="handleDragOver"
@dragleave="handleDragLeave" @dragleave="handleDragLeave"
@drop="handleDrop" @drop="handleDrop"
> >
<template v-if="editingPath.nodes && editingPath.nodes.length > 0"> <template v-if="editingPath.nodes && editingPath.nodes.length > 0">
<div <div
@@ -347,7 +347,7 @@
<el-tag size="small" type="info"> <el-tag size="small" type="info">
{{ getStageNodes(stage.name).length }} {{ getStageNodes(stage.name).length }}
</el-tag> </el-tag>
</div> </div>
<div class="stage-nodes"> <div class="stage-nodes">
<div <div
v-for="(node, nodeIndex) in getStageNodes(stage.name)" v-for="(node, nodeIndex) in getStageNodes(stage.name)"
@@ -356,7 +356,7 @@
> >
<div class="node-drag-handle"> <div class="node-drag-handle">
<el-icon><Rank /></el-icon> <el-icon><Rank /></el-icon>
</div> </div>
<div class="node-content"> <div class="node-content">
<span class="node-title">{{ node.title }}</span> <span class="node-title">{{ node.title }}</span>
<div class="node-meta"> <div class="node-meta">
@@ -367,10 +367,10 @@
style="cursor: pointer" style="cursor: pointer"
> >
{{ node.is_required ? '必修' : '选修' }} {{ node.is_required ? '必修' : '选修' }}
</el-tag> </el-tag>
<span>{{ node.estimated_days || 1 }}</span> <span>{{ node.estimated_days || 1 }}</span>
</div> </div>
</div> </div>
<div class="node-actions"> <div class="node-actions">
<el-select <el-select
v-model="node.stage_name" v-model="node.stage_name"
@@ -393,16 +393,16 @@
> >
<el-icon><Delete /></el-icon> <el-icon><Delete /></el-icon>
</el-button> </el-button>
</div> </div>
</div> </div>
<div <div
v-if="getStageNodes(stage.name).length === 0" v-if="getStageNodes(stage.name).length === 0"
class="stage-empty" class="stage-empty"
> >
拖拽或点击课程添加到此阶段 拖拽或点击课程添加到此阶段
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<el-empty v-else description="请从左侧添加课程" :image-size="60"> <el-empty v-else description="请从左侧添加课程" :image-size="60">
<template #description> <template #description>
@@ -413,7 +413,7 @@
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
</template> </template>
@@ -768,10 +768,10 @@ const handleDeletePath = async (row: GrowthPathListItem) => {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定要删除路径"${row.name}"吗?此操作不可恢复。`, `确定要删除路径"${row.name}"吗?此操作不可恢复。`,
'删除确认', '删除确认',
{ {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning',
} }
) )
@@ -1040,9 +1040,9 @@ onMounted(() => {
flex-shrink: 0; flex-shrink: 0;
.top-section { .top-section {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 16px; padding: 16px;
h3 { h3 {
@@ -1059,7 +1059,7 @@ onMounted(() => {
flex: 2; flex: 2;
.form-row { .form-row {
display: flex; display: flex;
gap: 16px; gap: 16px;
margin-bottom: 8px; margin-bottom: 8px;
@@ -1113,9 +1113,9 @@ onMounted(() => {
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
h3 { h3 {
margin: 0; margin: 0;
padding: 0; padding: 0;
border: none; border: none;
@@ -1134,7 +1134,7 @@ onMounted(() => {
.stage-order { .stage-order {
color: #667eea; color: #667eea;
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
} }
} }
@@ -1150,7 +1150,7 @@ onMounted(() => {
gap: 8px; gap: 8px;
.stat-box { .stat-box {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 8px 4px; padding: 8px 4px;
background: #f5f7fa; background: #f5f7fa;
@@ -1188,8 +1188,8 @@ onMounted(() => {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.panel-header { .panel-header {
display: flex; display: flex;
@@ -1254,7 +1254,7 @@ onMounted(() => {
.course-name { .course-name {
display: block; display: block;
font-size: 13px; font-size: 13px;
color: #333; color: #333;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -1266,34 +1266,34 @@ onMounted(() => {
font-size: 11px; font-size: 11px;
color: #909399; color: #909399;
margin-top: 2px; margin-top: 2px;
}
} }
} }
} }
} }
}
// 右侧已选课程 2/3 // 右侧已选课程 2/3
.selected-courses-panel { .selected-courses-panel {
flex: 1; flex: 1;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.panel-header { .panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 16px; padding: 12px 16px;
background: #fafafa; background: #fafafa;
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
font-weight: 500; font-weight: 500;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
} }
.selected-content { .selected-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px; padding: 12px;
border: 2px dashed transparent; border: 2px dashed transparent;
@@ -1341,7 +1341,7 @@ onMounted(() => {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
background: #fff; background: #fff;
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
border-radius: 6px; border-radius: 6px;
margin-bottom: 8px; margin-bottom: 8px;
@@ -1359,28 +1359,28 @@ onMounted(() => {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
.node-title { .node-title {
display: block; display: block;
font-weight: 500; font-weight: 500;
color: #333; color: #333;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.node-meta { .node-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
} }
} }
.node-actions { .node-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
} }
@@ -1390,12 +1390,12 @@ onMounted(() => {
padding: 16px; padding: 16px;
color: #909399; color: #909399;
font-size: 13px; font-size: 13px;
}
} }
} }
} }
} }
} }
}
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
@@ -1424,10 +1424,10 @@ onMounted(() => {
} }
.editor-bottom { .editor-bottom {
flex-direction: column; flex-direction: column;
.course-library-panel { .course-library-panel {
width: 100%; width: 100%;
max-height: 300px; max-height: 300px;
} }
} }