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 端点
"""
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))

View File

@@ -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):

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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,
}

View File

@@ -2,9 +2,9 @@
<div class="growth-path-management-container">
<!-- 路径列表视图 -->
<template v-if="!editingPath">
<div class="page-header">
<h1 class="page-title">成长路径管理</h1>
<div class="header-actions">
<div class="page-header">
<h1 class="page-title">成长路径管理</h1>
<div class="header-actions">
<el-select
v-model="filters.position_id"
placeholder="筛选岗位"
@@ -19,7 +19,7 @@
:label="pos.name"
:value="pos.id"
/>
</el-select>
</el-select>
<el-select
v-model="filters.is_active"
placeholder="状态"
@@ -34,9 +34,9 @@
<el-button type="primary" @click="handleCreatePath">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建路径
</el-button>
</div>
</el-button>
</div>
</div>
<!-- 路径列表 -->
<div class="path-list-card">
@@ -157,7 +157,7 @@
multiple
collapse-tags
collapse-tags-tooltip
clearable
clearable
style="width: 100%"
>
<el-option
@@ -170,7 +170,7 @@
<el-button
link
type="primary"
size="small"
size="small"
@click="handleSelectAllPositions"
class="select-all-btn"
>
@@ -228,11 +228,11 @@
placeholder="阶段名称"
size="small"
style="width: 100px"
>
<template #prefix>
>
<template #prefix>
<span class="stage-order">{{ index + 1 }}</span>
</template>
</el-input>
</template>
</el-input>
<el-button
link
type="danger"
@@ -244,8 +244,8 @@
</el-button>
</div>
</div>
</div>
</div>
<!-- 路径统计 -->
<div class="top-section stats-section">
<h3>路径统计</h3>
@@ -253,7 +253,7 @@
<div class="stat-box">
<span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span>
<span class="stat-label">课程总数</span>
</div>
</div>
<div class="stat-box">
<span class="stat-value">{{ requiredCount }}</span>
<span class="stat-label">必修课程</span>
@@ -261,10 +261,10 @@
<div class="stat-box">
<span class="stat-value">{{ totalDuration }}</span>
<span class="stat-label">总学时(h)</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 下方左右分栏 -->
<div class="editor-bottom">
@@ -303,11 +303,11 @@
<div class="course-list" v-loading="coursesLoading">
<div
v-for="course in filteredCourses"
:key="course.id"
class="course-item"
:key="course.id"
class="course-item"
:class="{ 'is-added': isNodeAdded(course.id) }"
draggable="true"
@dragstart="handleDragStart($event, course)"
draggable="true"
@dragstart="handleDragStart($event, course)"
@click="handleAddCourse(course)"
>
<div class="course-info">
@@ -320,21 +320,21 @@
<el-icon v-else><Plus /></el-icon>
</div>
<el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" />
</div>
</div>
</div>
</div>
<!-- 右侧 2/3已选课程配置 -->
<div class="selected-courses-panel">
<div class="panel-header">
<span>已选课程配置</span>
<el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} </el-tag>
</div>
</div>
<div
class="selected-content"
:class="{ 'is-dragging-over': isDraggingOver }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@drop="handleDrop"
>
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
<div
@@ -347,7 +347,7 @@
<el-tag size="small" type="info">
{{ getStageNodes(stage.name).length }}
</el-tag>
</div>
</div>
<div class="stage-nodes">
<div
v-for="(node, nodeIndex) in getStageNodes(stage.name)"
@@ -356,7 +356,7 @@
>
<div class="node-drag-handle">
<el-icon><Rank /></el-icon>
</div>
</div>
<div class="node-content">
<span class="node-title">{{ node.title }}</span>
<div class="node-meta">
@@ -367,10 +367,10 @@
style="cursor: pointer"
>
{{ node.is_required ? '必修' : '选修' }}
</el-tag>
</el-tag>
<span>{{ node.estimated_days || 1 }}</span>
</div>
</div>
</div>
</div>
<div class="node-actions">
<el-select
v-model="node.stage_name"
@@ -393,16 +393,16 @@
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<div
v-if="getStageNodes(stage.name).length === 0"
class="stage-empty"
>
拖拽或点击课程添加到此阶段
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<el-empty v-else description="请从左侧添加课程" :image-size="60">
<template #description>
@@ -413,7 +413,7 @@
</div>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -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;
}
}