Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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='用户节点完成记录表';
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user