Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,234 +1,234 @@
|
|||||||
"""
|
"""
|
||||||
成长路径 API 端点
|
成长路径 API 端点
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db, get_current_user
|
from app.core.deps import get_db, get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.growth_path_service import growth_path_service
|
from app.services.growth_path_service import growth_path_service
|
||||||
from app.schemas.growth_path import (
|
from app.schemas.growth_path import (
|
||||||
GrowthPathCreate,
|
GrowthPathCreate,
|
||||||
GrowthPathUpdate,
|
GrowthPathUpdate,
|
||||||
GrowthPathResponse,
|
GrowthPathResponse,
|
||||||
GrowthPathListResponse,
|
GrowthPathListResponse,
|
||||||
TraineeGrowthPathResponse,
|
TraineeGrowthPathResponse,
|
||||||
UserGrowthPathProgressResponse,
|
UserGrowthPathProgressResponse,
|
||||||
StartGrowthPathRequest,
|
StartGrowthPathRequest,
|
||||||
CompleteNodeRequest,
|
CompleteNodeRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# 学员端 API
|
# 学员端 API
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
@router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse])
|
@router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse])
|
||||||
async def get_trainee_growth_path(
|
async def get_trainee_growth_path(
|
||||||
position_id: Optional[int] = Query(None, description="岗位ID,不传则自动匹配"),
|
position_id: Optional[int] = Query(None, description="岗位ID,不传则自动匹配"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取学员的成长路径(含进度)
|
获取学员的成长路径(含进度)
|
||||||
|
|
||||||
返回数据包含:
|
返回数据包含:
|
||||||
- 成长路径基本信息
|
- 成长路径基本信息
|
||||||
- 各阶段及节点信息
|
- 各阶段及节点信息
|
||||||
- 每个节点的学习状态(locked/unlocked/in_progress/completed)
|
- 每个节点的学习状态(locked/unlocked/in_progress/completed)
|
||||||
- 每个节点的课程学习进度
|
- 每个节点的课程学习进度
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await growth_path_service.get_trainee_growth_path(
|
result = await growth_path_service.get_trainee_growth_path(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
position_id=position_id
|
position_id=position_id
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取成长路径失败: {e}")
|
logger.error(f"获取成长路径失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trainee/growth-path/start")
|
@router.post("/trainee/growth-path/start")
|
||||||
async def start_growth_path(
|
async def start_growth_path(
|
||||||
request: StartGrowthPathRequest,
|
request: StartGrowthPathRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
开始学习成长路径
|
开始学习成长路径
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
progress = await growth_path_service.start_growth_path(
|
progress = await growth_path_service.start_growth_path(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
growth_path_id=request.growth_path_id
|
growth_path_id=request.growth_path_id
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "已开始学习成长路径",
|
"message": "已开始学习成长路径",
|
||||||
"progress_id": progress.id,
|
"progress_id": progress.id,
|
||||||
}
|
}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"开始成长路径失败: {e}")
|
logger.error(f"开始成长路径失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trainee/growth-path/node/complete")
|
@router.post("/trainee/growth-path/node/complete")
|
||||||
async def complete_growth_path_node(
|
async def complete_growth_path_node(
|
||||||
request: CompleteNodeRequest,
|
request: CompleteNodeRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
完成成长路径节点
|
完成成长路径节点
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await growth_path_service.complete_node(
|
result = await growth_path_service.complete_node(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
node_id=request.node_id
|
node_id=request.node_id
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"完成节点失败: {e}")
|
logger.error(f"完成节点失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# 管理端 API
|
# 管理端 API
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
@router.get("/manager/growth-paths")
|
@router.get("/manager/growth-paths")
|
||||||
async def list_growth_paths(
|
async def list_growth_paths(
|
||||||
position_id: Optional[int] = Query(None, description="岗位ID筛选"),
|
position_id: Optional[int] = Query(None, description="岗位ID筛选"),
|
||||||
is_active: Optional[bool] = Query(None, description="是否启用"),
|
is_active: Optional[bool] = Query(None, description="是否启用"),
|
||||||
page: int = Query(1, ge=1, description="页码"),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取成长路径列表(管理端)
|
获取成长路径列表(管理端)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await growth_path_service.list_growth_paths(
|
result = await growth_path_service.list_growth_paths(
|
||||||
db=db,
|
db=db,
|
||||||
position_id=position_id,
|
position_id=position_id,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size
|
page_size=page_size
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取成长路径列表失败: {e}")
|
logger.error(f"获取成长路径列表失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/manager/growth-paths")
|
@router.post("/manager/growth-paths")
|
||||||
async def create_growth_path(
|
async def create_growth_path(
|
||||||
data: GrowthPathCreate,
|
data: GrowthPathCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
创建成长路径(管理端)
|
创建成长路径(管理端)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
growth_path = await growth_path_service.create_growth_path(
|
growth_path = await growth_path_service.create_growth_path(
|
||||||
db=db,
|
db=db,
|
||||||
data=data,
|
data=data,
|
||||||
created_by=current_user.id
|
created_by=current_user.id
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "创建成功",
|
"message": "创建成功",
|
||||||
"id": growth_path.id,
|
"id": growth_path.id,
|
||||||
}
|
}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"创建成长路径失败: {e}")
|
logger.error(f"创建成长路径失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/manager/growth-paths/{path_id}")
|
@router.get("/manager/growth-paths/{path_id}")
|
||||||
async def get_growth_path(
|
async def get_growth_path(
|
||||||
path_id: int,
|
path_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取成长路径详情(管理端)
|
获取成长路径详情(管理端)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await growth_path_service.get_growth_path(db=db, path_id=path_id)
|
result = await growth_path_service.get_growth_path(db=db, path_id=path_id)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="成长路径不存在")
|
raise HTTPException(status_code=404, detail="成长路径不存在")
|
||||||
return result
|
return result
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取成长路径详情失败: {e}")
|
logger.error(f"获取成长路径详情失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/manager/growth-paths/{path_id}")
|
@router.put("/manager/growth-paths/{path_id}")
|
||||||
async def update_growth_path(
|
async def update_growth_path(
|
||||||
path_id: int,
|
path_id: int,
|
||||||
data: GrowthPathUpdate,
|
data: GrowthPathUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
更新成长路径(管理端)
|
更新成长路径(管理端)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await growth_path_service.update_growth_path(
|
await growth_path_service.update_growth_path(
|
||||||
db=db,
|
db=db,
|
||||||
path_id=path_id,
|
path_id=path_id,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "更新成功",
|
"message": "更新成功",
|
||||||
}
|
}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"更新成长路径失败: {e}")
|
logger.error(f"更新成长路径失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/manager/growth-paths/{path_id}")
|
@router.delete("/manager/growth-paths/{path_id}")
|
||||||
async def delete_growth_path(
|
async def delete_growth_path(
|
||||||
path_id: int,
|
path_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
删除成长路径(管理端)
|
删除成长路径(管理端)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await growth_path_service.delete_growth_path(db=db, path_id=path_id)
|
await growth_path_service.delete_growth_path(db=db, path_id=path_id)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "删除成功",
|
"message": "删除成功",
|
||||||
}
|
}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"删除成长路径失败: {e}")
|
logger.error(f"删除成长路径失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -131,6 +132,20 @@ os.makedirs(upload_path, exist_ok=True)
|
|||||||
app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads")
|
app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads")
|
||||||
|
|
||||||
|
|
||||||
|
# 请求验证错误处理 (422)
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
"""处理请求验证错误,记录详细日志"""
|
||||||
|
logger.error(f"请求验证错误 [{request.method} {request.url.path}]: {exc.errors()}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"detail": exc.errors(),
|
||||||
|
"body": exc.body if hasattr(exc, 'body') else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# 全局异常处理
|
# 全局异常处理
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request, exc):
|
async def global_exception_handler(request, exc):
|
||||||
|
|||||||
@@ -1,118 +1,118 @@
|
|||||||
-- 成长路径功能数据库迁移脚本
|
-- 成长路径功能数据库迁移脚本
|
||||||
-- 创建时间: 2026-01-30
|
-- 创建时间: 2026-01-30
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 1. 修改 growth_paths 表,添加岗位关联
|
-- 1. 修改 growth_paths 表,添加岗位关联
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
ALTER TABLE growth_paths
|
ALTER TABLE growth_paths
|
||||||
ADD COLUMN IF NOT EXISTS position_id INT NULL COMMENT '关联岗位ID' AFTER target_role,
|
ADD COLUMN IF NOT EXISTS position_id INT NULL COMMENT '关联岗位ID' AFTER target_role,
|
||||||
ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses,
|
ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses,
|
||||||
ADD INDEX idx_position_id (position_id);
|
ADD INDEX idx_position_id (position_id);
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 2. 创建成长路径节点表
|
-- 2. 创建成长路径节点表
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
CREATE TABLE IF NOT EXISTS growth_path_nodes (
|
CREATE TABLE IF NOT EXISTS growth_path_nodes (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||||
course_id INT NOT NULL COMMENT '课程ID',
|
course_id INT NOT NULL COMMENT '课程ID',
|
||||||
stage_name VARCHAR(100) NULL COMMENT '所属阶段名称',
|
stage_name VARCHAR(100) NULL COMMENT '所属阶段名称',
|
||||||
title VARCHAR(200) NOT NULL COMMENT '节点标题',
|
title VARCHAR(200) NOT NULL COMMENT '节点标题',
|
||||||
description TEXT NULL COMMENT '节点描述',
|
description TEXT NULL COMMENT '节点描述',
|
||||||
order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序',
|
order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序',
|
||||||
is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修',
|
is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修',
|
||||||
prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]',
|
prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]',
|
||||||
estimated_days INT DEFAULT 7 COMMENT '预计学习天数',
|
estimated_days INT DEFAULT 7 COMMENT '预计学习天数',
|
||||||
|
|
||||||
-- 软删除
|
-- 软删除
|
||||||
is_deleted BOOLEAN DEFAULT FALSE NOT NULL,
|
is_deleted BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
deleted_at DATETIME NULL,
|
deleted_at DATETIME NULL,
|
||||||
|
|
||||||
-- 时间戳
|
-- 时间戳
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
-- 索引
|
-- 索引
|
||||||
INDEX idx_growth_path_id (growth_path_id),
|
INDEX idx_growth_path_id (growth_path_id),
|
||||||
INDEX idx_course_id (course_id),
|
INDEX idx_course_id (course_id),
|
||||||
INDEX idx_stage_name (stage_name),
|
INDEX idx_stage_name (stage_name),
|
||||||
INDEX idx_order_num (order_num),
|
INDEX idx_order_num (order_num),
|
||||||
INDEX idx_is_deleted (is_deleted),
|
INDEX idx_is_deleted (is_deleted),
|
||||||
|
|
||||||
-- 外键
|
-- 外键
|
||||||
CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id)
|
CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id)
|
||||||
REFERENCES growth_paths(id) ON DELETE CASCADE,
|
REFERENCES growth_paths(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_gpn_course FOREIGN KEY (course_id)
|
CONSTRAINT fk_gpn_course FOREIGN KEY (course_id)
|
||||||
REFERENCES courses(id) ON DELETE CASCADE
|
REFERENCES courses(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='成长路径节点表';
|
COMMENT='成长路径节点表';
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 3. 创建用户成长路径进度表
|
-- 3. 创建用户成长路径进度表
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
CREATE TABLE IF NOT EXISTS user_growth_path_progress (
|
CREATE TABLE IF NOT EXISTS user_growth_path_progress (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT NOT NULL COMMENT '用户ID',
|
user_id INT NOT NULL COMMENT '用户ID',
|
||||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||||
current_node_id INT NULL COMMENT '当前学习节点ID',
|
current_node_id INT NULL COMMENT '当前学习节点ID',
|
||||||
completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]',
|
completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]',
|
||||||
total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比',
|
total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比',
|
||||||
status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed',
|
status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed',
|
||||||
|
|
||||||
-- 时间记录
|
-- 时间记录
|
||||||
started_at DATETIME NULL COMMENT '开始时间',
|
started_at DATETIME NULL COMMENT '开始时间',
|
||||||
completed_at DATETIME NULL COMMENT '完成时间',
|
completed_at DATETIME NULL COMMENT '完成时间',
|
||||||
last_activity_at DATETIME NULL COMMENT '最后活动时间',
|
last_activity_at DATETIME NULL COMMENT '最后活动时间',
|
||||||
|
|
||||||
-- 时间戳
|
-- 时间戳
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
-- 索引
|
-- 索引
|
||||||
INDEX idx_user_id (user_id),
|
INDEX idx_user_id (user_id),
|
||||||
INDEX idx_growth_path_id (growth_path_id),
|
INDEX idx_growth_path_id (growth_path_id),
|
||||||
INDEX idx_status (status),
|
INDEX idx_status (status),
|
||||||
UNIQUE KEY uk_user_growth_path (user_id, growth_path_id),
|
UNIQUE KEY uk_user_growth_path (user_id, growth_path_id),
|
||||||
|
|
||||||
-- 外键
|
-- 外键
|
||||||
CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id)
|
CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id)
|
||||||
REFERENCES users(id) ON DELETE CASCADE,
|
REFERENCES users(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id)
|
CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id)
|
||||||
REFERENCES growth_paths(id) ON DELETE CASCADE
|
REFERENCES growth_paths(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='用户成长路径进度表';
|
COMMENT='用户成长路径进度表';
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况)
|
-- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
CREATE TABLE IF NOT EXISTS user_node_completions (
|
CREATE TABLE IF NOT EXISTS user_node_completions (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT NOT NULL COMMENT '用户ID',
|
user_id INT NOT NULL COMMENT '用户ID',
|
||||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||||
node_id INT NOT NULL COMMENT '节点ID',
|
node_id INT NOT NULL COMMENT '节点ID',
|
||||||
course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度',
|
course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度',
|
||||||
status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed',
|
status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed',
|
||||||
|
|
||||||
-- 时间记录
|
-- 时间记录
|
||||||
unlocked_at DATETIME NULL COMMENT '解锁时间',
|
unlocked_at DATETIME NULL COMMENT '解锁时间',
|
||||||
started_at DATETIME NULL COMMENT '开始学习时间',
|
started_at DATETIME NULL COMMENT '开始学习时间',
|
||||||
completed_at DATETIME NULL COMMENT '完成时间',
|
completed_at DATETIME NULL COMMENT '完成时间',
|
||||||
|
|
||||||
-- 时间戳
|
-- 时间戳
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
-- 索引
|
-- 索引
|
||||||
INDEX idx_user_id (user_id),
|
INDEX idx_user_id (user_id),
|
||||||
INDEX idx_growth_path_id (growth_path_id),
|
INDEX idx_growth_path_id (growth_path_id),
|
||||||
INDEX idx_node_id (node_id),
|
INDEX idx_node_id (node_id),
|
||||||
INDEX idx_status (status),
|
INDEX idx_status (status),
|
||||||
UNIQUE KEY uk_user_node (user_id, node_id),
|
UNIQUE KEY uk_user_node (user_id, node_id),
|
||||||
|
|
||||||
-- 外键
|
-- 外键
|
||||||
CONSTRAINT fk_unc_user FOREIGN KEY (user_id)
|
CONSTRAINT fk_unc_user FOREIGN KEY (user_id)
|
||||||
REFERENCES users(id) ON DELETE CASCADE,
|
REFERENCES users(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_unc_node FOREIGN KEY (node_id)
|
CONSTRAINT fk_unc_node FOREIGN KEY (node_id)
|
||||||
REFERENCES growth_path_nodes(id) ON DELETE CASCADE
|
REFERENCES growth_path_nodes(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='用户节点完成记录表';
|
COMMENT='用户节点完成记录表';
|
||||||
|
|||||||
@@ -1,206 +1,206 @@
|
|||||||
"""
|
"""
|
||||||
成长路径相关数据库模型
|
成长路径相关数据库模型
|
||||||
"""
|
"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
Integer,
|
Integer,
|
||||||
Boolean,
|
Boolean,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Enum as SQLEnum,
|
Enum as SQLEnum,
|
||||||
JSON,
|
JSON,
|
||||||
DateTime,
|
DateTime,
|
||||||
DECIMAL,
|
DECIMAL,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import BaseModel, SoftDeleteMixin
|
from app.models.base import BaseModel, SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class GrowthPathStatus(str, Enum):
|
class GrowthPathStatus(str, Enum):
|
||||||
"""成长路径学习状态"""
|
"""成长路径学习状态"""
|
||||||
NOT_STARTED = "not_started" # 未开始
|
NOT_STARTED = "not_started" # 未开始
|
||||||
IN_PROGRESS = "in_progress" # 进行中
|
IN_PROGRESS = "in_progress" # 进行中
|
||||||
COMPLETED = "completed" # 已完成
|
COMPLETED = "completed" # 已完成
|
||||||
|
|
||||||
|
|
||||||
class NodeStatus(str, Enum):
|
class NodeStatus(str, Enum):
|
||||||
"""节点状态"""
|
"""节点状态"""
|
||||||
LOCKED = "locked" # 锁定(前置未完成)
|
LOCKED = "locked" # 锁定(前置未完成)
|
||||||
UNLOCKED = "unlocked" # 已解锁(可以开始)
|
UNLOCKED = "unlocked" # 已解锁(可以开始)
|
||||||
IN_PROGRESS = "in_progress" # 学习中
|
IN_PROGRESS = "in_progress" # 学习中
|
||||||
COMPLETED = "completed" # 已完成
|
COMPLETED = "completed" # 已完成
|
||||||
|
|
||||||
|
|
||||||
class GrowthPathNode(BaseModel, SoftDeleteMixin):
|
class GrowthPathNode(BaseModel, SoftDeleteMixin):
|
||||||
"""
|
"""
|
||||||
成长路径节点表
|
成长路径节点表
|
||||||
每个节点对应一门课程
|
每个节点对应一门课程
|
||||||
"""
|
"""
|
||||||
__tablename__ = "growth_path_nodes"
|
__tablename__ = "growth_path_nodes"
|
||||||
|
|
||||||
# 关联
|
# 关联
|
||||||
growth_path_id: Mapped[int] = mapped_column(
|
growth_path_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="成长路径ID"
|
comment="成长路径ID"
|
||||||
)
|
)
|
||||||
course_id: Mapped[int] = mapped_column(
|
course_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("courses.id", ondelete="CASCADE"),
|
ForeignKey("courses.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="课程ID"
|
comment="课程ID"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 节点信息
|
# 节点信息
|
||||||
stage_name: Mapped[Optional[str]] = mapped_column(
|
stage_name: Mapped[Optional[str]] = mapped_column(
|
||||||
String(100), nullable=True, comment="所属阶段名称"
|
String(100), nullable=True, comment="所属阶段名称"
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(
|
title: Mapped[str] = mapped_column(
|
||||||
String(200), nullable=False, comment="节点标题"
|
String(200), nullable=False, comment="节点标题"
|
||||||
)
|
)
|
||||||
description: Mapped[Optional[str]] = mapped_column(
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
Text, nullable=True, comment="节点描述"
|
Text, nullable=True, comment="节点描述"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
order_num: Mapped[int] = mapped_column(
|
order_num: Mapped[int] = mapped_column(
|
||||||
Integer, default=0, nullable=False, comment="排序顺序"
|
Integer, default=0, nullable=False, comment="排序顺序"
|
||||||
)
|
)
|
||||||
is_required: Mapped[bool] = mapped_column(
|
is_required: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=True, nullable=False, comment="是否必修"
|
Boolean, default=True, nullable=False, comment="是否必修"
|
||||||
)
|
)
|
||||||
prerequisites: Mapped[Optional[List[int]]] = mapped_column(
|
prerequisites: Mapped[Optional[List[int]]] = mapped_column(
|
||||||
JSON, nullable=True, comment="前置节点IDs"
|
JSON, nullable=True, comment="前置节点IDs"
|
||||||
)
|
)
|
||||||
estimated_days: Mapped[int] = mapped_column(
|
estimated_days: Mapped[int] = mapped_column(
|
||||||
Integer, default=7, nullable=False, comment="预计学习天数"
|
Integer, default=7, nullable=False, comment="预计学习天数"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
growth_path: Mapped["GrowthPath"] = relationship(
|
growth_path: Mapped["GrowthPath"] = relationship(
|
||||||
"GrowthPath", back_populates="nodes"
|
"GrowthPath", back_populates="nodes"
|
||||||
)
|
)
|
||||||
course: Mapped["Course"] = relationship("Course")
|
course: Mapped["Course"] = relationship("Course")
|
||||||
user_completions: Mapped[List["UserNodeCompletion"]] = relationship(
|
user_completions: Mapped[List["UserNodeCompletion"]] = relationship(
|
||||||
"UserNodeCompletion", back_populates="node"
|
"UserNodeCompletion", back_populates="node"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserGrowthPathProgress(BaseModel):
|
class UserGrowthPathProgress(BaseModel):
|
||||||
"""
|
"""
|
||||||
用户成长路径进度表
|
用户成长路径进度表
|
||||||
记录用户在某条成长路径上的整体进度
|
记录用户在某条成长路径上的整体进度
|
||||||
"""
|
"""
|
||||||
__tablename__ = "user_growth_path_progress"
|
__tablename__ = "user_growth_path_progress"
|
||||||
|
|
||||||
# 关联
|
# 关联
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="用户ID"
|
comment="用户ID"
|
||||||
)
|
)
|
||||||
growth_path_id: Mapped[int] = mapped_column(
|
growth_path_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="成长路径ID"
|
comment="成长路径ID"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 进度信息
|
# 进度信息
|
||||||
current_node_id: Mapped[Optional[int]] = mapped_column(
|
current_node_id: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True, comment="当前学习节点ID"
|
Integer, nullable=True, comment="当前学习节点ID"
|
||||||
)
|
)
|
||||||
completed_node_ids: Mapped[Optional[List[int]]] = mapped_column(
|
completed_node_ids: Mapped[Optional[List[int]]] = mapped_column(
|
||||||
JSON, nullable=True, comment="已完成节点IDs"
|
JSON, nullable=True, comment="已完成节点IDs"
|
||||||
)
|
)
|
||||||
total_progress: Mapped[Decimal] = mapped_column(
|
total_progress: Mapped[Decimal] = mapped_column(
|
||||||
DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比"
|
DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 状态
|
# 状态
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
default=GrowthPathStatus.NOT_STARTED.value,
|
default=GrowthPathStatus.NOT_STARTED.value,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="状态"
|
comment="状态"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 时间记录
|
# 时间记录
|
||||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, comment="开始时间"
|
DateTime(timezone=True), nullable=True, comment="开始时间"
|
||||||
)
|
)
|
||||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, comment="完成时间"
|
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||||
)
|
)
|
||||||
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, comment="最后活动时间"
|
DateTime(timezone=True), nullable=True, comment="最后活动时间"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
user: Mapped["User"] = relationship("User")
|
user: Mapped["User"] = relationship("User")
|
||||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||||
|
|
||||||
|
|
||||||
class UserNodeCompletion(BaseModel):
|
class UserNodeCompletion(BaseModel):
|
||||||
"""
|
"""
|
||||||
用户节点完成记录表
|
用户节点完成记录表
|
||||||
详细记录用户在每个节点上的学习状态
|
详细记录用户在每个节点上的学习状态
|
||||||
"""
|
"""
|
||||||
__tablename__ = "user_node_completions"
|
__tablename__ = "user_node_completions"
|
||||||
|
|
||||||
# 关联
|
# 关联
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="用户ID"
|
comment="用户ID"
|
||||||
)
|
)
|
||||||
growth_path_id: Mapped[int] = mapped_column(
|
growth_path_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="成长路径ID"
|
comment="成长路径ID"
|
||||||
)
|
)
|
||||||
node_id: Mapped[int] = mapped_column(
|
node_id: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("growth_path_nodes.id", ondelete="CASCADE"),
|
ForeignKey("growth_path_nodes.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="节点ID"
|
comment="节点ID"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 进度信息
|
# 进度信息
|
||||||
course_progress: Mapped[Decimal] = mapped_column(
|
course_progress: Mapped[Decimal] = mapped_column(
|
||||||
DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度"
|
DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度"
|
||||||
)
|
)
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
default=NodeStatus.LOCKED.value,
|
default=NodeStatus.LOCKED.value,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="状态"
|
comment="状态"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 时间记录
|
# 时间记录
|
||||||
unlocked_at: Mapped[Optional[datetime]] = mapped_column(
|
unlocked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, comment="解锁时间"
|
DateTime(timezone=True), nullable=True, comment="解锁时间"
|
||||||
)
|
)
|
||||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, comment="开始学习时间"
|
DateTime(timezone=True), nullable=True, comment="开始学习时间"
|
||||||
)
|
)
|
||||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, comment="完成时间"
|
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
user: Mapped["User"] = relationship("User")
|
user: Mapped["User"] = relationship("User")
|
||||||
node: Mapped["GrowthPathNode"] = relationship(
|
node: Mapped["GrowthPathNode"] = relationship(
|
||||||
"GrowthPathNode", back_populates="user_completions"
|
"GrowthPathNode", back_populates="user_completions"
|
||||||
)
|
)
|
||||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||||
|
|||||||
@@ -1,226 +1,226 @@
|
|||||||
"""
|
"""
|
||||||
成长路径相关 Schema
|
成长路径相关 Schema
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# 基础数据结构
|
# 基础数据结构
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
class StageConfig(BaseModel):
|
class StageConfig(BaseModel):
|
||||||
"""阶段配置"""
|
"""阶段配置"""
|
||||||
name: str = Field(..., description="阶段名称")
|
name: str = Field(..., description="阶段名称")
|
||||||
description: Optional[str] = Field(None, description="阶段描述")
|
description: Optional[str] = Field(None, description="阶段描述")
|
||||||
order: int = Field(0, description="排序")
|
order: int = Field(0, description="排序")
|
||||||
|
|
||||||
|
|
||||||
class NodeBase(BaseModel):
|
class NodeBase(BaseModel):
|
||||||
"""节点基础信息"""
|
"""节点基础信息"""
|
||||||
course_id: int = Field(..., description="课程ID")
|
course_id: int = Field(..., description="课程ID")
|
||||||
stage_name: Optional[str] = Field(None, description="所属阶段名称")
|
stage_name: Optional[str] = Field(None, description="所属阶段名称")
|
||||||
title: str = Field(..., description="节点标题")
|
title: str = Field(..., description="节点标题")
|
||||||
description: Optional[str] = Field(None, description="节点描述")
|
description: Optional[str] = Field(None, description="节点描述")
|
||||||
order_num: int = Field(0, description="排序顺序")
|
order_num: int = Field(0, description="排序顺序")
|
||||||
is_required: bool = Field(True, description="是否必修")
|
is_required: bool = Field(True, description="是否必修")
|
||||||
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
|
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
|
||||||
estimated_days: int = Field(7, description="预计学习天数")
|
estimated_days: int = Field(7, description="预计学习天数")
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# 管理端 - 创建/更新
|
# 管理端 - 创建/更新
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
class GrowthPathNodeCreate(NodeBase):
|
class GrowthPathNodeCreate(NodeBase):
|
||||||
"""创建节点"""
|
"""创建节点"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GrowthPathNodeUpdate(BaseModel):
|
class GrowthPathNodeUpdate(BaseModel):
|
||||||
"""更新节点"""
|
"""更新节点"""
|
||||||
course_id: Optional[int] = None
|
course_id: Optional[int] = None
|
||||||
stage_name: Optional[str] = None
|
stage_name: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
order_num: Optional[int] = None
|
order_num: Optional[int] = None
|
||||||
is_required: Optional[bool] = None
|
is_required: Optional[bool] = None
|
||||||
prerequisites: Optional[List[int]] = None
|
prerequisites: Optional[List[int]] = None
|
||||||
estimated_days: Optional[int] = None
|
estimated_days: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class GrowthPathCreate(BaseModel):
|
class GrowthPathCreate(BaseModel):
|
||||||
"""创建成长路径"""
|
"""创建成长路径"""
|
||||||
name: str = Field(..., description="路径名称")
|
name: str = Field(..., description="路径名称")
|
||||||
description: Optional[str] = Field(None, description="路径描述")
|
description: Optional[str] = Field(None, description="路径描述")
|
||||||
target_role: Optional[str] = Field(None, description="目标角色")
|
target_role: Optional[str] = Field(None, description="目标角色")
|
||||||
position_id: Optional[int] = Field(None, description="关联岗位ID(兼容旧版)")
|
position_id: Optional[int] = Field(None, description="关联岗位ID(兼容旧版)")
|
||||||
position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表(支持多选)")
|
position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表(支持多选)")
|
||||||
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
|
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
|
||||||
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
|
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
|
||||||
is_active: bool = Field(True, description="是否启用")
|
is_active: bool = Field(True, description="是否启用")
|
||||||
sort_order: int = Field(0, description="排序")
|
sort_order: int = Field(0, description="排序")
|
||||||
nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表")
|
nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表")
|
||||||
|
|
||||||
|
|
||||||
class GrowthPathUpdate(BaseModel):
|
class GrowthPathUpdate(BaseModel):
|
||||||
"""更新成长路径"""
|
"""更新成长路径"""
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
target_role: Optional[str] = None
|
target_role: Optional[str] = None
|
||||||
position_id: Optional[int] = None
|
position_id: Optional[int] = None
|
||||||
position_ids: Optional[List[int]] = None
|
position_ids: Optional[List[int]] = None
|
||||||
stages: Optional[List[StageConfig]] = None
|
stages: Optional[List[StageConfig]] = None
|
||||||
estimated_duration_days: Optional[int] = None
|
estimated_duration_days: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点
|
nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# 管理端 - 响应
|
# 管理端 - 响应
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
class GrowthPathNodeResponse(NodeBase):
|
class GrowthPathNodeResponse(NodeBase):
|
||||||
"""节点响应"""
|
"""节点响应"""
|
||||||
id: int
|
id: int
|
||||||
growth_path_id: int
|
growth_path_id: int
|
||||||
course_name: Optional[str] = None # 课程名称(关联查询)
|
course_name: Optional[str] = None # 课程名称(关联查询)
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class GrowthPathResponse(BaseModel):
|
class GrowthPathResponse(BaseModel):
|
||||||
"""成长路径响应(管理端)"""
|
"""成长路径响应(管理端)"""
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
target_role: Optional[str] = None
|
target_role: Optional[str] = None
|
||||||
position_id: Optional[int] = None
|
position_id: Optional[int] = None
|
||||||
position_name: Optional[str] = None # 岗位名称(关联查询)
|
position_name: Optional[str] = None # 岗位名称(关联查询)
|
||||||
stages: Optional[List[StageConfig]] = None
|
stages: Optional[List[StageConfig]] = None
|
||||||
estimated_duration_days: Optional[int] = None
|
estimated_duration_days: Optional[int] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
sort_order: int
|
sort_order: int
|
||||||
nodes: List[GrowthPathNodeResponse] = []
|
nodes: List[GrowthPathNodeResponse] = []
|
||||||
node_count: int = 0 # 节点数量
|
node_count: int = 0 # 节点数量
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class GrowthPathListResponse(BaseModel):
|
class GrowthPathListResponse(BaseModel):
|
||||||
"""成长路径列表响应"""
|
"""成长路径列表响应"""
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
position_id: Optional[int] = None
|
position_id: Optional[int] = None
|
||||||
position_name: Optional[str] = None
|
position_name: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
node_count: int = 0
|
node_count: int = 0
|
||||||
estimated_duration_days: Optional[int] = None
|
estimated_duration_days: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# 学员端 - 响应
|
# 学员端 - 响应
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
class TraineeNodeResponse(BaseModel):
|
class TraineeNodeResponse(BaseModel):
|
||||||
"""学员端节点响应(含进度状态)"""
|
"""学员端节点响应(含进度状态)"""
|
||||||
id: int
|
id: int
|
||||||
course_id: int
|
course_id: int
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
stage_name: Optional[str] = None
|
stage_name: Optional[str] = None
|
||||||
is_required: bool
|
is_required: bool
|
||||||
estimated_days: int
|
estimated_days: int
|
||||||
order_num: int
|
order_num: int
|
||||||
|
|
||||||
# 学员特有
|
# 学员特有
|
||||||
status: str = Field(..., description="状态: locked/unlocked/in_progress/completed")
|
status: str = Field(..., description="状态: locked/unlocked/in_progress/completed")
|
||||||
progress: float = Field(0, description="课程学习进度 0-100")
|
progress: float = Field(0, description="课程学习进度 0-100")
|
||||||
|
|
||||||
# 课程信息
|
# 课程信息
|
||||||
course_name: Optional[str] = None
|
course_name: Optional[str] = None
|
||||||
course_cover: Optional[str] = None
|
course_cover: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class TraineeStageResponse(BaseModel):
|
class TraineeStageResponse(BaseModel):
|
||||||
"""学员端阶段响应"""
|
"""学员端阶段响应"""
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
completed: int = Field(0, description="已完成节点数")
|
completed: int = Field(0, description="已完成节点数")
|
||||||
total: int = Field(0, description="总节点数")
|
total: int = Field(0, description="总节点数")
|
||||||
nodes: List[TraineeNodeResponse] = []
|
nodes: List[TraineeNodeResponse] = []
|
||||||
|
|
||||||
|
|
||||||
class TraineeGrowthPathResponse(BaseModel):
|
class TraineeGrowthPathResponse(BaseModel):
|
||||||
"""学员端成长路径响应"""
|
"""学员端成长路径响应"""
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
position_id: Optional[int] = None
|
position_id: Optional[int] = None
|
||||||
position_name: Optional[str] = None
|
position_name: Optional[str] = None
|
||||||
|
|
||||||
# 进度信息
|
# 进度信息
|
||||||
total_progress: float = Field(0, description="总进度百分比")
|
total_progress: float = Field(0, description="总进度百分比")
|
||||||
completed_nodes: int = Field(0, description="已完成节点数")
|
completed_nodes: int = Field(0, description="已完成节点数")
|
||||||
total_nodes: int = Field(0, description="总节点数")
|
total_nodes: int = Field(0, description="总节点数")
|
||||||
status: str = Field("not_started", description="状态: not_started/in_progress/completed")
|
status: str = Field("not_started", description="状态: not_started/in_progress/completed")
|
||||||
|
|
||||||
# 时间信息
|
# 时间信息
|
||||||
started_at: Optional[datetime] = None
|
started_at: Optional[datetime] = None
|
||||||
estimated_completion_days: Optional[int] = None
|
estimated_completion_days: Optional[int] = None
|
||||||
|
|
||||||
# 阶段和节点
|
# 阶段和节点
|
||||||
stages: List[TraineeStageResponse] = []
|
stages: List[TraineeStageResponse] = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# 用户进度
|
# 用户进度
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
class UserGrowthPathProgressResponse(BaseModel):
|
class UserGrowthPathProgressResponse(BaseModel):
|
||||||
"""用户成长路径进度响应"""
|
"""用户成长路径进度响应"""
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
growth_path_id: int
|
growth_path_id: int
|
||||||
growth_path_name: str
|
growth_path_name: str
|
||||||
current_node_id: Optional[int] = None
|
current_node_id: Optional[int] = None
|
||||||
current_node_title: Optional[str] = None
|
current_node_title: Optional[str] = None
|
||||||
completed_node_ids: List[int] = []
|
completed_node_ids: List[int] = []
|
||||||
total_progress: float
|
total_progress: float
|
||||||
status: str
|
status: str
|
||||||
started_at: Optional[datetime] = None
|
started_at: Optional[datetime] = None
|
||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
last_activity_at: Optional[datetime] = None
|
last_activity_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class StartGrowthPathRequest(BaseModel):
|
class StartGrowthPathRequest(BaseModel):
|
||||||
"""开始学习成长路径请求"""
|
"""开始学习成长路径请求"""
|
||||||
growth_path_id: int = Field(..., description="成长路径ID")
|
growth_path_id: int = Field(..., description="成长路径ID")
|
||||||
|
|
||||||
|
|
||||||
class CompleteNodeRequest(BaseModel):
|
class CompleteNodeRequest(BaseModel):
|
||||||
"""完成节点请求"""
|
"""完成节点请求"""
|
||||||
node_id: int = Field(..., description="节点ID")
|
node_id: int = Field(..., description="节点ID")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
工具模块
|
工具模块
|
||||||
"""
|
"""
|
||||||
from app.utils.score_distributor import (
|
from app.utils.score_distributor import (
|
||||||
ScoreDistributor,
|
ScoreDistributor,
|
||||||
distribute_scores,
|
distribute_scores,
|
||||||
get_question_score,
|
get_question_score,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ScoreDistributor",
|
"ScoreDistributor",
|
||||||
"distribute_scores",
|
"distribute_scores",
|
||||||
"get_question_score",
|
"get_question_score",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,218 +1,218 @@
|
|||||||
"""
|
"""
|
||||||
分数分配工具
|
分数分配工具
|
||||||
|
|
||||||
解决题目分数无法整除的问题,确保:
|
解决题目分数无法整除的问题,确保:
|
||||||
1. 所有题目分数之和精确等于总分
|
1. 所有题目分数之和精确等于总分
|
||||||
2. 题目分数差异最小化(最多相差1分)
|
2. 题目分数差异最小化(最多相差1分)
|
||||||
3. 支持整数分配和小数分配两种模式
|
3. 支持整数分配和小数分配两种模式
|
||||||
"""
|
"""
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
|
||||||
class ScoreDistributor:
|
class ScoreDistributor:
|
||||||
"""
|
"""
|
||||||
智能分数分配器
|
智能分数分配器
|
||||||
|
|
||||||
使用示例:
|
使用示例:
|
||||||
distributor = ScoreDistributor(total_score=100, question_count=6)
|
distributor = ScoreDistributor(total_score=100, question_count=6)
|
||||||
scores = distributor.distribute()
|
scores = distributor.distribute()
|
||||||
# 结果: [17, 17, 17, 17, 16, 16] 总和=100
|
# 结果: [17, 17, 17, 17, 16, 16] 总和=100
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, total_score: float, question_count: int):
|
def __init__(self, total_score: float, question_count: int):
|
||||||
"""
|
"""
|
||||||
初始化分配器
|
初始化分配器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
total_score: 总分(如 100)
|
total_score: 总分(如 100)
|
||||||
question_count: 题目数量(如 6)
|
question_count: 题目数量(如 6)
|
||||||
"""
|
"""
|
||||||
if question_count <= 0:
|
if question_count <= 0:
|
||||||
raise ValueError("题目数量必须大于0")
|
raise ValueError("题目数量必须大于0")
|
||||||
if total_score <= 0:
|
if total_score <= 0:
|
||||||
raise ValueError("总分必须大于0")
|
raise ValueError("总分必须大于0")
|
||||||
|
|
||||||
self.total_score = total_score
|
self.total_score = total_score
|
||||||
self.question_count = question_count
|
self.question_count = question_count
|
||||||
|
|
||||||
def distribute_integer(self) -> List[int]:
|
def distribute_integer(self) -> List[int]:
|
||||||
"""
|
"""
|
||||||
整数分配模式
|
整数分配模式
|
||||||
|
|
||||||
将总分分配为整数,前面的题目分数可能比后面的多1分
|
将总分分配为整数,前面的题目分数可能比后面的多1分
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
分数列表,如 [17, 17, 17, 17, 16, 16]
|
分数列表,如 [17, 17, 17, 17, 16, 16]
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
||||||
100分 / 7题 = [15, 15, 14, 14, 14, 14, 14]
|
100分 / 7题 = [15, 15, 14, 14, 14, 14, 14]
|
||||||
"""
|
"""
|
||||||
total = int(self.total_score)
|
total = int(self.total_score)
|
||||||
count = self.question_count
|
count = self.question_count
|
||||||
|
|
||||||
# 基础分数(向下取整)
|
# 基础分数(向下取整)
|
||||||
base_score = total // count
|
base_score = total // count
|
||||||
# 需要额外加1分的题目数量
|
# 需要额外加1分的题目数量
|
||||||
extra_count = total % count
|
extra_count = total % count
|
||||||
|
|
||||||
# 生成分数列表
|
# 生成分数列表
|
||||||
scores = []
|
scores = []
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
if i < extra_count:
|
if i < extra_count:
|
||||||
scores.append(base_score + 1)
|
scores.append(base_score + 1)
|
||||||
else:
|
else:
|
||||||
scores.append(base_score)
|
scores.append(base_score)
|
||||||
|
|
||||||
return scores
|
return scores
|
||||||
|
|
||||||
def distribute_decimal(self, decimal_places: int = 1) -> List[float]:
|
def distribute_decimal(self, decimal_places: int = 1) -> List[float]:
|
||||||
"""
|
"""
|
||||||
小数分配模式
|
小数分配模式
|
||||||
|
|
||||||
将总分分配为小数,最后一题用于补齐差额
|
将总分分配为小数,最后一题用于补齐差额
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
decimal_places: 小数位数,默认1位
|
decimal_places: 小数位数,默认1位
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5]
|
分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5]
|
||||||
"""
|
"""
|
||||||
count = self.question_count
|
count = self.question_count
|
||||||
|
|
||||||
# 计算每题分数并四舍五入
|
# 计算每题分数并四舍五入
|
||||||
per_score = self.total_score / count
|
per_score = self.total_score / count
|
||||||
rounded_score = round(per_score, decimal_places)
|
rounded_score = round(per_score, decimal_places)
|
||||||
|
|
||||||
# 前 n-1 题使用四舍五入的分数
|
# 前 n-1 题使用四舍五入的分数
|
||||||
scores = [rounded_score] * (count - 1)
|
scores = [rounded_score] * (count - 1)
|
||||||
|
|
||||||
# 最后一题用总分减去前面的和,确保总分精确
|
# 最后一题用总分减去前面的和,确保总分精确
|
||||||
last_score = round(self.total_score - sum(scores), decimal_places)
|
last_score = round(self.total_score - sum(scores), decimal_places)
|
||||||
scores.append(last_score)
|
scores.append(last_score)
|
||||||
|
|
||||||
return scores
|
return scores
|
||||||
|
|
||||||
def distribute(self, mode: str = "integer") -> List[float]:
|
def distribute(self, mode: str = "integer") -> List[float]:
|
||||||
"""
|
"""
|
||||||
分配分数
|
分配分数
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mode: 分配模式
|
mode: 分配模式
|
||||||
- "integer": 整数分配(推荐)
|
- "integer": 整数分配(推荐)
|
||||||
- "decimal": 小数分配
|
- "decimal": 小数分配
|
||||||
- "decimal_1": 保留1位小数
|
- "decimal_1": 保留1位小数
|
||||||
- "decimal_2": 保留2位小数
|
- "decimal_2": 保留2位小数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
分数列表
|
分数列表
|
||||||
"""
|
"""
|
||||||
if mode == "integer":
|
if mode == "integer":
|
||||||
return [float(s) for s in self.distribute_integer()]
|
return [float(s) for s in self.distribute_integer()]
|
||||||
elif mode == "decimal" or mode == "decimal_1":
|
elif mode == "decimal" or mode == "decimal_1":
|
||||||
return self.distribute_decimal(1)
|
return self.distribute_decimal(1)
|
||||||
elif mode == "decimal_2":
|
elif mode == "decimal_2":
|
||||||
return self.distribute_decimal(2)
|
return self.distribute_decimal(2)
|
||||||
else:
|
else:
|
||||||
return [float(s) for s in self.distribute_integer()]
|
return [float(s) for s in self.distribute_integer()]
|
||||||
|
|
||||||
def get_score_for_question(self, question_index: int, mode: str = "integer") -> float:
|
def get_score_for_question(self, question_index: int, mode: str = "integer") -> float:
|
||||||
"""
|
"""
|
||||||
获取指定题目的分数
|
获取指定题目的分数
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
question_index: 题目索引(从0开始)
|
question_index: 题目索引(从0开始)
|
||||||
mode: 分配模式
|
mode: 分配模式
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
该题目的分数
|
该题目的分数
|
||||||
"""
|
"""
|
||||||
scores = self.distribute(mode)
|
scores = self.distribute(mode)
|
||||||
if 0 <= question_index < len(scores):
|
if 0 <= question_index < len(scores):
|
||||||
return scores[question_index]
|
return scores[question_index]
|
||||||
raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})")
|
raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})")
|
||||||
|
|
||||||
def validate(self) -> Tuple[bool, str]:
|
def validate(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
验证分配结果
|
验证分配结果
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(是否有效, 信息)
|
(是否有效, 信息)
|
||||||
"""
|
"""
|
||||||
scores = self.distribute()
|
scores = self.distribute()
|
||||||
total = sum(scores)
|
total = sum(scores)
|
||||||
|
|
||||||
if abs(total - self.total_score) < 0.01:
|
if abs(total - self.total_score) < 0.01:
|
||||||
return True, f"分配有效:{scores},总分={total}"
|
return True, f"分配有效:{scores},总分={total}"
|
||||||
else:
|
else:
|
||||||
return False, f"分配无效:{scores},总分={total},期望={self.total_score}"
|
return False, f"分配无效:{scores},总分={total},期望={self.total_score}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_score(score: float, decimal_places: int = 1) -> str:
|
def format_score(score: float, decimal_places: int = 1) -> str:
|
||||||
"""
|
"""
|
||||||
格式化分数显示
|
格式化分数显示
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
score: 分数
|
score: 分数
|
||||||
decimal_places: 小数位数
|
decimal_places: 小数位数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
格式化的分数字符串
|
格式化的分数字符串
|
||||||
"""
|
"""
|
||||||
if score == int(score):
|
if score == int(score):
|
||||||
return str(int(score))
|
return str(int(score))
|
||||||
return f"{score:.{decimal_places}f}"
|
return f"{score:.{decimal_places}f}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float:
|
def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float:
|
||||||
"""
|
"""
|
||||||
计算及格分数
|
计算及格分数
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
total_score: 总分
|
total_score: 总分
|
||||||
pass_rate: 及格率,默认60%
|
pass_rate: 及格率,默认60%
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
及格分数(整数)
|
及格分数(整数)
|
||||||
"""
|
"""
|
||||||
return math.ceil(total_score * pass_rate)
|
return math.ceil(total_score * pass_rate)
|
||||||
|
|
||||||
|
|
||||||
def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]:
|
def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]:
|
||||||
"""
|
"""
|
||||||
便捷函数:分配分数
|
便捷函数:分配分数
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
total_score: 总分
|
total_score: 总分
|
||||||
question_count: 题目数量
|
question_count: 题目数量
|
||||||
mode: 分配模式(integer/decimal)
|
mode: 分配模式(integer/decimal)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
分数列表
|
分数列表
|
||||||
"""
|
"""
|
||||||
distributor = ScoreDistributor(total_score, question_count)
|
distributor = ScoreDistributor(total_score, question_count)
|
||||||
return distributor.distribute(mode)
|
return distributor.distribute(mode)
|
||||||
|
|
||||||
|
|
||||||
def get_question_score(
|
def get_question_score(
|
||||||
total_score: float,
|
total_score: float,
|
||||||
question_count: int,
|
question_count: int,
|
||||||
question_index: int,
|
question_index: int,
|
||||||
mode: str = "integer"
|
mode: str = "integer"
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
便捷函数:获取指定题目的分数
|
便捷函数:获取指定题目的分数
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
total_score: 总分
|
total_score: 总分
|
||||||
question_count: 题目数量
|
question_count: 题目数量
|
||||||
question_index: 题目索引(从0开始)
|
question_index: 题目索引(从0开始)
|
||||||
mode: 分配模式
|
mode: 分配模式
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
该题目的分数
|
该题目的分数
|
||||||
"""
|
"""
|
||||||
distributor = ScoreDistributor(total_score, question_count)
|
distributor = ScoreDistributor(total_score, question_count)
|
||||||
return distributor.get_score_for_question(question_index, mode)
|
return distributor.get_score_for_question(question_index, mode)
|
||||||
|
|||||||
@@ -1,201 +1,201 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
修复历史考试的小数分数问题
|
修复历史考试的小数分数问题
|
||||||
|
|
||||||
将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数
|
将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数
|
||||||
使用智能整数分配算法,确保所有题目分数之和等于总分
|
使用智能整数分配算法,确保所有题目分数之和等于总分
|
||||||
|
|
||||||
使用方法:
|
使用方法:
|
||||||
# 在后端容器中执行
|
# 在后端容器中执行
|
||||||
cd /app
|
cd /app
|
||||||
python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改
|
python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改
|
||||||
python scripts/fix_exam_scores.py # 实际执行修复
|
python scripts/fix_exam_scores.py # 实际执行修复
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
# 添加项目路径
|
# 添加项目路径
|
||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
|
|
||||||
def distribute_integer_scores(total_score: float, question_count: int) -> list:
|
def distribute_integer_scores(total_score: float, question_count: int) -> list:
|
||||||
"""
|
"""
|
||||||
整数分配分数
|
整数分配分数
|
||||||
|
|
||||||
将总分分配为整数,前面的题目分数可能比后面的多1分
|
将总分分配为整数,前面的题目分数可能比后面的多1分
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
||||||
100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5]
|
100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5]
|
||||||
"""
|
"""
|
||||||
total = int(total_score)
|
total = int(total_score)
|
||||||
count = question_count
|
count = question_count
|
||||||
|
|
||||||
# 基础分数(向下取整)
|
# 基础分数(向下取整)
|
||||||
base_score = total // count
|
base_score = total // count
|
||||||
# 需要额外加1分的题目数量
|
# 需要额外加1分的题目数量
|
||||||
extra_count = total % count
|
extra_count = total % count
|
||||||
|
|
||||||
# 生成分数列表
|
# 生成分数列表
|
||||||
scores = []
|
scores = []
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
if i < extra_count:
|
if i < extra_count:
|
||||||
scores.append(base_score + 1)
|
scores.append(base_score + 1)
|
||||||
else:
|
else:
|
||||||
scores.append(base_score)
|
scores.append(base_score)
|
||||||
|
|
||||||
return scores
|
return scores
|
||||||
|
|
||||||
|
|
||||||
def is_decimal_score(score) -> bool:
|
def is_decimal_score(score) -> bool:
|
||||||
"""检查分数是否是小数(非整数)"""
|
"""检查分数是否是小数(非整数)"""
|
||||||
if score is None:
|
if score is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
score_float = float(score)
|
score_float = float(score)
|
||||||
return score_float != int(score_float)
|
return score_float != int(score_float)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def fix_exam_scores(dry_run: bool = True, db_url: str = None):
|
def fix_exam_scores(dry_run: bool = True, db_url: str = None):
|
||||||
"""
|
"""
|
||||||
修复考试分数
|
修复考试分数
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dry_run: 如果为 True,只预览不实际修改
|
dry_run: 如果为 True,只预览不实际修改
|
||||||
db_url: 数据库连接字符串,如果为 None 则从环境变量读取
|
db_url: 数据库连接字符串,如果为 None 则从环境变量读取
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
# 获取数据库连接
|
# 获取数据库连接
|
||||||
if db_url is None:
|
if db_url is None:
|
||||||
db_url = os.environ.get('DATABASE_URL')
|
db_url = os.environ.get('DATABASE_URL')
|
||||||
if not db_url:
|
if not db_url:
|
||||||
# 尝试从配置文件读取
|
# 尝试从配置文件读取
|
||||||
try:
|
try:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
db_url = settings.DATABASE_URL
|
db_url = settings.DATABASE_URL
|
||||||
except:
|
except:
|
||||||
print("错误:无法获取数据库连接字符串")
|
print("错误:无法获取数据库连接字符串")
|
||||||
print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行")
|
print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 将 mysql+aiomysql:// 转换为 mysql+pymysql://
|
# 将 mysql+aiomysql:// 转换为 mysql+pymysql://
|
||||||
if 'aiomysql' in db_url:
|
if 'aiomysql' in db_url:
|
||||||
db_url = db_url.replace('aiomysql', 'pymysql')
|
db_url = db_url.replace('aiomysql', 'pymysql')
|
||||||
|
|
||||||
print(f"连接数据库...")
|
print(f"连接数据库...")
|
||||||
engine = create_engine(db_url, echo=False)
|
engine = create_engine(db_url, echo=False)
|
||||||
Session = sessionmaker(bind=engine)
|
Session = sessionmaker(bind=engine)
|
||||||
session = Session()
|
session = Session()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 查询所有考试记录
|
# 查询所有考试记录
|
||||||
result = session.execute(text("""
|
result = session.execute(text("""
|
||||||
SELECT id, user_id, course_id, exam_name, question_count, total_score, questions
|
SELECT id, user_id, course_id, exam_name, question_count, total_score, questions
|
||||||
FROM exams
|
FROM exams
|
||||||
WHERE questions IS NOT NULL
|
WHERE questions IS NOT NULL
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
exams = result.fetchall()
|
exams = result.fetchall()
|
||||||
print(f"找到 {len(exams)} 条考试记录")
|
print(f"找到 {len(exams)} 条考试记录")
|
||||||
|
|
||||||
fixed_count = 0
|
fixed_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
|
|
||||||
for exam in exams:
|
for exam in exams:
|
||||||
exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam
|
exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 解析 questions JSON
|
# 解析 questions JSON
|
||||||
if isinstance(questions_json, str):
|
if isinstance(questions_json, str):
|
||||||
questions = json.loads(questions_json)
|
questions = json.loads(questions_json)
|
||||||
else:
|
else:
|
||||||
questions = questions_json
|
questions = questions_json
|
||||||
|
|
||||||
if not questions or not isinstance(questions, list):
|
if not questions or not isinstance(questions, list):
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查是否有小数分数
|
# 检查是否有小数分数
|
||||||
has_decimal = False
|
has_decimal = False
|
||||||
for q in questions:
|
for q in questions:
|
||||||
if 'score' in q and is_decimal_score(q['score']):
|
if 'score' in q and is_decimal_score(q['score']):
|
||||||
has_decimal = True
|
has_decimal = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if not has_decimal:
|
if not has_decimal:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 计算新的整数分数
|
# 计算新的整数分数
|
||||||
actual_count = len(questions)
|
actual_count = len(questions)
|
||||||
actual_total = total_score or 100
|
actual_total = total_score or 100
|
||||||
new_scores = distribute_integer_scores(actual_total, actual_count)
|
new_scores = distribute_integer_scores(actual_total, actual_count)
|
||||||
|
|
||||||
# 更新每道题的分数
|
# 更新每道题的分数
|
||||||
old_scores = [q.get('score', 0) for q in questions]
|
old_scores = [q.get('score', 0) for q in questions]
|
||||||
for i, q in enumerate(questions):
|
for i, q in enumerate(questions):
|
||||||
q['score'] = new_scores[i]
|
q['score'] = new_scores[i]
|
||||||
|
|
||||||
# 验证总分
|
# 验证总分
|
||||||
new_total = sum(new_scores)
|
new_total = sum(new_scores)
|
||||||
if abs(new_total - actual_total) > 0.01:
|
if abs(new_total - actual_total) > 0.01:
|
||||||
print(f" 警告:exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}")
|
print(f" 警告:exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}")
|
||||||
error_count += 1
|
error_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}")
|
print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}")
|
||||||
print(f" 旧分数: {old_scores[:5]}..." if len(old_scores) > 5 else f" 旧分数: {old_scores}")
|
print(f" 旧分数: {old_scores[:5]}..." if len(old_scores) > 5 else f" 旧分数: {old_scores}")
|
||||||
print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}")
|
print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}")
|
||||||
else:
|
else:
|
||||||
# 实际更新数据库
|
# 实际更新数据库
|
||||||
new_json = json.dumps(questions, ensure_ascii=False)
|
new_json = json.dumps(questions, ensure_ascii=False)
|
||||||
session.execute(text("""
|
session.execute(text("""
|
||||||
UPDATE exams SET questions = :questions WHERE id = :exam_id
|
UPDATE exams SET questions = :questions WHERE id = :exam_id
|
||||||
"""), {"questions": new_json, "exam_id": exam_id})
|
"""), {"questions": new_json, "exam_id": exam_id})
|
||||||
print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}")
|
print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}")
|
||||||
|
|
||||||
fixed_count += 1
|
fixed_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" 错误:处理 exam_id={exam_id} 时出错: {e}")
|
print(f" 错误:处理 exam_id={exam_id} 时出错: {e}")
|
||||||
error_count += 1
|
error_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
session.commit()
|
session.commit()
|
||||||
print("\n已提交数据库更改")
|
print("\n已提交数据库更改")
|
||||||
|
|
||||||
print(f"\n=== 统计 ===")
|
print(f"\n=== 统计 ===")
|
||||||
print(f"需要修复: {fixed_count}")
|
print(f"需要修复: {fixed_count}")
|
||||||
print(f"已跳过(无小数): {skipped_count}")
|
print(f"已跳过(无小数): {skipped_count}")
|
||||||
print(f"错误: {error_count}")
|
print(f"错误: {error_count}")
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print("\n这是预览模式,未实际修改数据库。")
|
print("\n这是预览模式,未实际修改数据库。")
|
||||||
print("如需实际执行,请去掉 --dry-run 参数重新运行。")
|
print("如需实际执行,请去掉 --dry-run 参数重新运行。")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"执行失败: {e}")
|
print(f"执行失败: {e}")
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题")
|
parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库")
|
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库")
|
||||||
parser.add_argument("--db-url", type=str, help="数据库连接字符串")
|
parser.add_argument("--db-url", type=str, help="数据库连接字符串")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url)
|
fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url)
|
||||||
|
|||||||
@@ -1,154 +1,154 @@
|
|||||||
/**
|
/**
|
||||||
* 分数格式化工具
|
* 分数格式化工具
|
||||||
*
|
*
|
||||||
* 用于在前端显示分数时进行格式化,避免显示过长的小数
|
* 用于在前端显示分数时进行格式化,避免显示过长的小数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化分数显示
|
* 格式化分数显示
|
||||||
*
|
*
|
||||||
* @param score 分数
|
* @param score 分数
|
||||||
* @param decimalPlaces 小数位数,默认1位
|
* @param decimalPlaces 小数位数,默认1位
|
||||||
* @returns 格式化后的分数字符串
|
* @returns 格式化后的分数字符串
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* formatScore(16.666666) // "16.7"
|
* formatScore(16.666666) // "16.7"
|
||||||
* formatScore(17) // "17"
|
* formatScore(17) // "17"
|
||||||
* formatScore(16.5, 0) // "17"
|
* formatScore(16.5, 0) // "17"
|
||||||
*/
|
*/
|
||||||
export function formatScore(score: number, decimalPlaces: number = 1): string {
|
export function formatScore(score: number, decimalPlaces: number = 1): string {
|
||||||
// 如果是整数,直接返回
|
// 如果是整数,直接返回
|
||||||
if (Number.isInteger(score)) {
|
if (Number.isInteger(score)) {
|
||||||
return score.toString()
|
return score.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 四舍五入到指定小数位
|
// 四舍五入到指定小数位
|
||||||
const rounded = Number(score.toFixed(decimalPlaces))
|
const rounded = Number(score.toFixed(decimalPlaces))
|
||||||
|
|
||||||
// 如果四舍五入后是整数,去掉小数点
|
// 如果四舍五入后是整数,去掉小数点
|
||||||
if (Number.isInteger(rounded)) {
|
if (Number.isInteger(rounded)) {
|
||||||
return rounded.toString()
|
return rounded.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
return rounded.toFixed(decimalPlaces)
|
return rounded.toFixed(decimalPlaces)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化分数显示(带单位)
|
* 格式化分数显示(带单位)
|
||||||
*
|
*
|
||||||
* @param score 分数
|
* @param score 分数
|
||||||
* @param unit 单位,默认"分"
|
* @param unit 单位,默认"分"
|
||||||
* @returns 格式化后的分数字符串
|
* @returns 格式化后的分数字符串
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* formatScoreWithUnit(16.7) // "16.7分"
|
* formatScoreWithUnit(16.7) // "16.7分"
|
||||||
* formatScoreWithUnit(100) // "100分"
|
* formatScoreWithUnit(100) // "100分"
|
||||||
*/
|
*/
|
||||||
export function formatScoreWithUnit(score: number, unit: string = '分'): string {
|
export function formatScoreWithUnit(score: number, unit: string = '分'): string {
|
||||||
return `${formatScore(score)}${unit}`
|
return `${formatScore(score)}${unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化百分比
|
* 格式化百分比
|
||||||
*
|
*
|
||||||
* @param value 值(0-1 或 0-100)
|
* @param value 值(0-1 或 0-100)
|
||||||
* @param isPercent 是否已经是百分比形式(0-100),默认false
|
* @param isPercent 是否已经是百分比形式(0-100),默认false
|
||||||
* @returns 格式化后的百分比字符串
|
* @returns 格式化后的百分比字符串
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* formatPercent(0.8567) // "85.7%"
|
* formatPercent(0.8567) // "85.7%"
|
||||||
* formatPercent(85.67, true) // "85.7%"
|
* formatPercent(85.67, true) // "85.7%"
|
||||||
*/
|
*/
|
||||||
export function formatPercent(value: number, isPercent: boolean = false): string {
|
export function formatPercent(value: number, isPercent: boolean = false): string {
|
||||||
const percent = isPercent ? value : value * 100
|
const percent = isPercent ? value : value * 100
|
||||||
return `${formatScore(percent)}%`
|
return `${formatScore(percent)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算及格分数
|
* 计算及格分数
|
||||||
*
|
*
|
||||||
* @param totalScore 总分
|
* @param totalScore 总分
|
||||||
* @param passRate 及格率,默认0.6
|
* @param passRate 及格率,默认0.6
|
||||||
* @returns 及格分数(向上取整)
|
* @returns 及格分数(向上取整)
|
||||||
*/
|
*/
|
||||||
export function calculatePassScore(totalScore: number, passRate: number = 0.6): number {
|
export function calculatePassScore(totalScore: number, passRate: number = 0.6): number {
|
||||||
return Math.ceil(totalScore * passRate)
|
return Math.ceil(totalScore * passRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否及格
|
* 判断是否及格
|
||||||
*
|
*
|
||||||
* @param score 得分
|
* @param score 得分
|
||||||
* @param passScore 及格分数
|
* @param passScore 及格分数
|
||||||
* @returns 是否及格
|
* @returns 是否及格
|
||||||
*/
|
*/
|
||||||
export function isPassed(score: number, passScore: number): boolean {
|
export function isPassed(score: number, passScore: number): boolean {
|
||||||
return score >= passScore
|
return score >= passScore
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取分数等级
|
* 获取分数等级
|
||||||
*
|
*
|
||||||
* @param score 得分
|
* @param score 得分
|
||||||
* @param totalScore 总分
|
* @param totalScore 总分
|
||||||
* @returns 等级: 'excellent' | 'good' | 'pass' | 'fail'
|
* @returns 等级: 'excellent' | 'good' | 'pass' | 'fail'
|
||||||
*/
|
*/
|
||||||
export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' {
|
export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' {
|
||||||
const ratio = score / totalScore
|
const ratio = score / totalScore
|
||||||
|
|
||||||
if (ratio >= 0.9) return 'excellent'
|
if (ratio >= 0.9) return 'excellent'
|
||||||
if (ratio >= 0.75) return 'good'
|
if (ratio >= 0.75) return 'good'
|
||||||
if (ratio >= 0.6) return 'pass'
|
if (ratio >= 0.6) return 'pass'
|
||||||
return 'fail'
|
return 'fail'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取分数等级对应的颜色
|
* 获取分数等级对应的颜色
|
||||||
*
|
*
|
||||||
* @param level 等级
|
* @param level 等级
|
||||||
* @returns 颜色值
|
* @returns 颜色值
|
||||||
*/
|
*/
|
||||||
export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string {
|
export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string {
|
||||||
const colors = {
|
const colors = {
|
||||||
excellent: '#67c23a', // 绿色
|
excellent: '#67c23a', // 绿色
|
||||||
good: '#409eff', // 蓝色
|
good: '#409eff', // 蓝色
|
||||||
pass: '#e6a23c', // 橙色
|
pass: '#e6a23c', // 橙色
|
||||||
fail: '#f56c6c', // 红色
|
fail: '#f56c6c', // 红色
|
||||||
}
|
}
|
||||||
return colors[level]
|
return colors[level]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 智能分配分数(前端预览用)
|
* 智能分配分数(前端预览用)
|
||||||
*
|
*
|
||||||
* @param totalScore 总分
|
* @param totalScore 总分
|
||||||
* @param questionCount 题目数量
|
* @param questionCount 题目数量
|
||||||
* @returns 分数数组
|
* @returns 分数数组
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* distributeScores(100, 6) // [17, 17, 17, 17, 16, 16]
|
* distributeScores(100, 6) // [17, 17, 17, 17, 16, 16]
|
||||||
*/
|
*/
|
||||||
export function distributeScores(totalScore: number, questionCount: number): number[] {
|
export function distributeScores(totalScore: number, questionCount: number): number[] {
|
||||||
if (questionCount <= 0) return []
|
if (questionCount <= 0) return []
|
||||||
|
|
||||||
const baseScore = Math.floor(totalScore / questionCount)
|
const baseScore = Math.floor(totalScore / questionCount)
|
||||||
const extraCount = totalScore % questionCount
|
const extraCount = totalScore % questionCount
|
||||||
|
|
||||||
const scores: number[] = []
|
const scores: number[] = []
|
||||||
for (let i = 0; i < questionCount; i++) {
|
for (let i = 0; i < questionCount; i++) {
|
||||||
scores.push(i < extraCount ? baseScore + 1 : baseScore)
|
scores.push(i < extraCount ? baseScore + 1 : baseScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
return scores
|
return scores
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
formatScore,
|
formatScore,
|
||||||
formatScoreWithUnit,
|
formatScoreWithUnit,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
calculatePassScore,
|
calculatePassScore,
|
||||||
isPassed,
|
isPassed,
|
||||||
getScoreLevel,
|
getScoreLevel,
|
||||||
getScoreLevelColor,
|
getScoreLevelColor,
|
||||||
distributeScores,
|
distributeScores,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<div class="growth-path-management-container">
|
<div class="growth-path-management-container">
|
||||||
<!-- 路径列表视图 -->
|
<!-- 路径列表视图 -->
|
||||||
<template v-if="!editingPath">
|
<template v-if="!editingPath">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">成长路径管理</h1>
|
<h1 class="page-title">成长路径管理</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.position_id"
|
v-model="filters.position_id"
|
||||||
placeholder="筛选岗位"
|
placeholder="筛选岗位"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
:label="pos.name"
|
:label="pos.name"
|
||||||
:value="pos.id"
|
:value="pos.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.is_active"
|
v-model="filters.is_active"
|
||||||
placeholder="状态"
|
placeholder="状态"
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
<el-button type="primary" @click="handleCreatePath">
|
<el-button type="primary" @click="handleCreatePath">
|
||||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||||
新建路径
|
新建路径
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 路径列表 -->
|
<!-- 路径列表 -->
|
||||||
<div class="path-list-card">
|
<div class="path-list-card">
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
multiple
|
multiple
|
||||||
collapse-tags
|
collapse-tags
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
clearable
|
clearable
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleSelectAllPositions"
|
@click="handleSelectAllPositions"
|
||||||
class="select-all-btn"
|
class="select-all-btn"
|
||||||
>
|
>
|
||||||
@@ -228,11 +228,11 @@
|
|||||||
placeholder="阶段名称"
|
placeholder="阶段名称"
|
||||||
size="small"
|
size="small"
|
||||||
style="width: 100px"
|
style="width: 100px"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<span class="stage-order">{{ index + 1 }}</span>
|
<span class="stage-order">{{ index + 1 }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -244,8 +244,8 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 路径统计 -->
|
<!-- 路径统计 -->
|
||||||
<div class="top-section stats-section">
|
<div class="top-section stats-section">
|
||||||
<h3>路径统计</h3>
|
<h3>路径统计</h3>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span>
|
<span class="stat-value">{{ editingPath.nodes?.length || 0 }}</span>
|
||||||
<span class="stat-label">课程总数</span>
|
<span class="stat-label">课程总数</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">{{ requiredCount }}</span>
|
<span class="stat-value">{{ requiredCount }}</span>
|
||||||
<span class="stat-label">必修课程</span>
|
<span class="stat-label">必修课程</span>
|
||||||
@@ -261,10 +261,10 @@
|
|||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">{{ totalDuration }}</span>
|
<span class="stat-value">{{ totalDuration }}</span>
|
||||||
<span class="stat-label">总学时(h)</span>
|
<span class="stat-label">总学时(h)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 下方:左右分栏 -->
|
<!-- 下方:左右分栏 -->
|
||||||
<div class="editor-bottom">
|
<div class="editor-bottom">
|
||||||
@@ -303,11 +303,11 @@
|
|||||||
<div class="course-list" v-loading="coursesLoading">
|
<div class="course-list" v-loading="coursesLoading">
|
||||||
<div
|
<div
|
||||||
v-for="course in filteredCourses"
|
v-for="course in filteredCourses"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
class="course-item"
|
class="course-item"
|
||||||
:class="{ 'is-added': isNodeAdded(course.id) }"
|
:class="{ 'is-added': isNodeAdded(course.id) }"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="handleDragStart($event, course)"
|
@dragstart="handleDragStart($event, course)"
|
||||||
@click="handleAddCourse(course)"
|
@click="handleAddCourse(course)"
|
||||||
>
|
>
|
||||||
<div class="course-info">
|
<div class="course-info">
|
||||||
@@ -320,21 +320,21 @@
|
|||||||
<el-icon v-else><Plus /></el-icon>
|
<el-icon v-else><Plus /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" />
|
<el-empty v-if="filteredCourses.length === 0" description="暂无课程" :image-size="50" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧 2/3:已选课程配置 -->
|
<!-- 右侧 2/3:已选课程配置 -->
|
||||||
<div class="selected-courses-panel">
|
<div class="selected-courses-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span>已选课程配置</span>
|
<span>已选课程配置</span>
|
||||||
<el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} 门</el-tag>
|
<el-tag size="small" type="success">{{ editingPath.nodes?.length || 0 }} 门</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="selected-content"
|
class="selected-content"
|
||||||
:class="{ 'is-dragging-over': isDraggingOver }"
|
:class="{ 'is-dragging-over': isDraggingOver }"
|
||||||
@dragover.prevent="handleDragOver"
|
@dragover.prevent="handleDragOver"
|
||||||
@dragleave="handleDragLeave"
|
@dragleave="handleDragLeave"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
>
|
>
|
||||||
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
|
<template v-if="editingPath.nodes && editingPath.nodes.length > 0">
|
||||||
<div
|
<div
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
<el-tag size="small" type="info">
|
<el-tag size="small" type="info">
|
||||||
{{ getStageNodes(stage.name).length }} 门
|
{{ getStageNodes(stage.name).length }} 门
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="stage-nodes">
|
<div class="stage-nodes">
|
||||||
<div
|
<div
|
||||||
v-for="(node, nodeIndex) in getStageNodes(stage.name)"
|
v-for="(node, nodeIndex) in getStageNodes(stage.name)"
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
>
|
>
|
||||||
<div class="node-drag-handle">
|
<div class="node-drag-handle">
|
||||||
<el-icon><Rank /></el-icon>
|
<el-icon><Rank /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="node-content">
|
<div class="node-content">
|
||||||
<span class="node-title">{{ node.title }}</span>
|
<span class="node-title">{{ node.title }}</span>
|
||||||
<div class="node-meta">
|
<div class="node-meta">
|
||||||
@@ -367,10 +367,10 @@
|
|||||||
style="cursor: pointer"
|
style="cursor: pointer"
|
||||||
>
|
>
|
||||||
{{ node.is_required ? '必修' : '选修' }}
|
{{ node.is_required ? '必修' : '选修' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span>{{ node.estimated_days || 1 }}天</span>
|
<span>{{ node.estimated_days || 1 }}天</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="node-actions">
|
<div class="node-actions">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="node.stage_name"
|
v-model="node.stage_name"
|
||||||
@@ -393,16 +393,16 @@
|
|||||||
>
|
>
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="getStageNodes(stage.name).length === 0"
|
v-if="getStageNodes(stage.name).length === 0"
|
||||||
class="stage-empty"
|
class="stage-empty"
|
||||||
>
|
>
|
||||||
拖拽或点击课程添加到此阶段
|
拖拽或点击课程添加到此阶段
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else description="请从左侧添加课程" :image-size="60">
|
<el-empty v-else description="请从左侧添加课程" :image-size="60">
|
||||||
<template #description>
|
<template #description>
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -768,10 +768,10 @@ const handleDeletePath = async (row: GrowthPathListItem) => {
|
|||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定要删除路径"${row.name}"吗?此操作不可恢复。`,
|
`确定要删除路径"${row.name}"吗?此操作不可恢复。`,
|
||||||
'删除确认',
|
'删除确认',
|
||||||
{
|
{
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1040,9 +1040,9 @@ onMounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.top-section {
|
.top-section {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -1059,7 +1059,7 @@ onMounted(() => {
|
|||||||
flex: 2;
|
flex: 2;
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
@@ -1113,9 +1113,9 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1134,7 +1134,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.stage-order {
|
.stage-order {
|
||||||
color: #667eea;
|
color: #667eea;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1150,7 +1150,7 @@ onMounted(() => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.stat-box {
|
.stat-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
@@ -1188,8 +1188,8 @@ onMounted(() => {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1254,7 +1254,7 @@ onMounted(() => {
|
|||||||
.course-name {
|
.course-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1266,34 +1266,34 @@ onMounted(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 右侧已选课程 2/3
|
// 右侧已选课程 2/3
|
||||||
.selected-courses-panel {
|
.selected-courses-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-content {
|
.selected-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 2px dashed transparent;
|
border: 2px dashed transparent;
|
||||||
@@ -1341,7 +1341,7 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid #ebeef5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1359,28 +1359,28 @@ onMounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
.node-title {
|
.node-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: #333;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-meta {
|
.node-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-actions {
|
.node-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1390,12 +1390,12 @@ onMounted(() => {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -1424,10 +1424,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-bottom {
|
.editor-bottom {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.course-library-panel {
|
.course-library-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user