feat: 新增等级与奖章系统
Some checks failed
continuous-integration/drone/push Build is failing

- 后端: 新增 user_levels, exp_history, badge_definitions, user_badges, level_configs 表
- 后端: 新增 LevelService 和 BadgeService 服务
- 后端: 新增等级/奖章/签到/排行榜 API 端点
- 后端: 考试/练习/陪练完成时触发经验值和奖章检查
- 前端: 新增 LevelBadge, ExpProgress, BadgeCard, LevelUpDialog 组件
- 前端: 新增排行榜页面
- 前端: 成长路径页面集成真实等级数据
- 数据库: 包含迁移脚本和初始数据
This commit is contained in:
yuliang_guo
2026-01-29 16:19:22 +08:00
parent 5dfe23831d
commit 0933b936f9
19 changed files with 3207 additions and 65 deletions

View File

@@ -107,5 +107,8 @@ api_router.include_router(admin_portal_router, tags=["admin-portal"])
# system_settings_router 系统设置路由(企业管理员配置)
from .system_settings import router as system_settings_router
api_router.include_router(system_settings_router, prefix="/settings", tags=["system-settings"])
# level_router 等级与奖章路由
from .endpoints.level import router as level_router
api_router.include_router(level_router, prefix="/level", tags=["level"])
__all__ = ["api_router"]

View File

@@ -0,0 +1,277 @@
"""
等级与奖章 API
提供等级查询、奖章查询、排行榜、签到等接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
from app.models.user import User
router = APIRouter()
# ============================================
# 等级相关接口
# ============================================
@router.get("/me", response_model=ResponseModel)
async def get_my_level(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户等级信息
返回用户的等级、经验值、称号、连续登录天数等信息
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.get("/user/{user_id}", response_model=ResponseModel)
async def get_user_level(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取指定用户等级信息
Args:
user_id: 用户ID
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(user_id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.post("/checkin", response_model=ResponseModel)
async def daily_checkin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
每日签到
每天首次签到获得经验值,连续签到有额外奖励
"""
level_service = LevelService(db)
badge_service = BadgeService(db)
# 执行签到
checkin_result = await level_service.daily_checkin(current_user.id)
# 检查是否解锁新奖章
new_badges = []
if checkin_result["success"]:
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message=checkin_result["message"],
data={
**checkin_result,
"new_badges": new_badges
}
)
@router.get("/exp-history", response_model=ResponseModel)
async def get_exp_history(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取经验值变化历史
Args:
limit: 每页数量默认50最大100
offset: 偏移量
exp_type: 类型筛选exam/practice/training/task/login/badge/other
"""
level_service = LevelService(db)
history, total = await level_service.get_exp_history(
user_id=current_user.id,
limit=limit,
offset=offset,
exp_type=exp_type
)
return ResponseModel(
message="获取成功",
data={
"items": history,
"total": total,
"limit": limit,
"offset": offset
}
)
@router.get("/leaderboard", response_model=ResponseModel)
async def get_leaderboard(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级排行榜
Args:
limit: 每页数量默认50最大100
offset: 偏移量
"""
level_service = LevelService(db)
# 获取排行榜
leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset)
# 获取当前用户排名
my_rank = await level_service.get_user_rank(current_user.id)
# 获取当前用户等级信息
my_level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data={
"items": leaderboard,
"total": total,
"limit": limit,
"offset": offset,
"my_rank": my_rank,
"my_level_info": my_level_info
}
)
# ============================================
# 奖章相关接口
# ============================================
@router.get("/badges/all", response_model=ResponseModel)
async def get_all_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有奖章定义
返回所有可获得的奖章列表
"""
badge_service = BadgeService(db)
badges = await badge_service.get_all_badges()
return ResponseModel(
message="获取成功",
data=badges
)
@router.get("/badges/me", response_model=ResponseModel)
async def get_my_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的奖章(含解锁状态)
返回所有奖章及用户是否已解锁
"""
badge_service = BadgeService(db)
badges = await badge_service.get_user_badges_with_status(current_user.id)
# 统计已解锁数量
unlocked_count = sum(1 for b in badges if b["unlocked"])
return ResponseModel(
message="获取成功",
data={
"badges": badges,
"total": len(badges),
"unlocked_count": unlocked_count
}
)
@router.get("/badges/unnotified", response_model=ResponseModel)
async def get_unnotified_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取未通知的新奖章
用于前端显示新获得奖章的弹窗提示
"""
badge_service = BadgeService(db)
badges = await badge_service.get_unnotified_badges(current_user.id)
return ResponseModel(
message="获取成功",
data=badges
)
@router.post("/badges/mark-notified", response_model=ResponseModel)
async def mark_badges_notified(
badge_ids: Optional[list[int]] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
标记奖章为已通知
Args:
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
badge_service = BadgeService(db)
await badge_service.mark_badges_notified(current_user.id, badge_ids)
await db.commit()
return ResponseModel(
message="标记成功"
)
@router.post("/check-badges", response_model=ResponseModel)
async def check_and_award_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
检查并授予符合条件的奖章
手动触发奖章检查,返回新获得的奖章
"""
badge_service = BadgeService(db)
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message="检查完成",
data={
"new_badges": new_badges,
"count": len(new_badges)
}
)

View File

@@ -134,8 +134,44 @@ async def submit_exam(
user_agent=http_request.headers.get("user-agent")
)
)
return ResponseModel(code=200, data=SubmitExamResponse(**result), message="考试提交成功")
# 考试通过时触发经验值和奖章检查
exp_result = None
new_badges = []
if result.get("is_passed"):
try:
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
level_service = LevelService(db)
badge_service = BadgeService(db)
# 添加考试经验值
exp_result = await level_service.add_exam_exp(
user_id=current_user.id,
exam_id=request.exam_id,
score=result.get("total_score", 0),
is_passed=True
)
# 检查是否解锁新奖章
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
except Exception as e:
logger.warning(f"考试经验值/奖章处理失败: {str(e)}")
# 将经验值结果添加到返回数据
response_data = SubmitExamResponse(**result)
return ResponseModel(
code=200,
data={
**response_data.model_dump(),
"exp_result": exp_result,
"new_badges": new_badges
},
message="考试提交成功"
)
@router.get("/mistakes", response_model=ResponseModel[GetMistakesResponse])

View File

@@ -704,10 +704,37 @@ async def end_practice_session(
logger.info(f"结束陪练会话: session_id={session_id}, 时长={session.duration_seconds}秒, 轮次={session.turns}")
# 练习完成时触发经验值和奖章检查
exp_result = None
new_badges = []
try:
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
level_service = LevelService(db)
badge_service = BadgeService(db)
# 添加练习经验值
exp_result = await level_service.add_practice_exp(
user_id=current_user.id,
session_id=session.id
)
# 检查是否解锁新奖章
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
except Exception as e:
logger.warning(f"练习经验值/奖章处理失败: {str(e)}")
return ResponseModel(
code=200,
message="会话已结束",
data=session
data={
"session": session,
"exp_result": exp_result,
"new_badges": new_badges
}
)
except HTTPException:

View File

@@ -261,8 +261,40 @@ async def end_training(
)
logger.info(f"用户 {current_user['id']} 结束陪练会话: {session_id}")
# 陪练完成时触发经验值和奖章检查
exp_result = None
new_badges = []
try:
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
level_service = LevelService(db)
badge_service = BadgeService(db)
# 获取陪练得分(如果有报告的话)
score = response.get("total_score") if isinstance(response, dict) else None
# 添加陪练经验值
exp_result = await level_service.add_training_exp(
user_id=current_user["id"],
session_id=session_id,
score=score
)
# 检查是否解锁新奖章
new_badges = await badge_service.check_and_award_badges(current_user["id"])
await db.commit()
except Exception as e:
logger.warning(f"陪练经验值/奖章处理失败: {str(e)}")
# 将经验值结果添加到返回数据
result_data = response if isinstance(response, dict) else {"session_id": session_id}
result_data["exp_result"] = exp_result
result_data["new_badges"] = new_badges
return ResponseModel(data=response, message="结束陪练成功")
return ResponseModel(data=result_data, message="结束陪练成功")
except HTTPException:
raise

View File

@@ -17,6 +17,16 @@ from app.models.practice import PracticeScene, PracticeSession, PracticeDialogue
from app.models.system_log import SystemLog
from app.models.task import Task, TaskCourse, TaskAssignment
from app.models.notification import Notification
from app.models.level import (
UserLevel,
ExpHistory,
BadgeDefinition,
UserBadge,
LevelConfig,
ExpType,
BadgeCategory,
ConditionType,
)
__all__ = [
"Base",
@@ -46,4 +56,12 @@ __all__ = [
"TaskCourse",
"TaskAssignment",
"Notification",
"UserLevel",
"ExpHistory",
"BadgeDefinition",
"UserBadge",
"LevelConfig",
"ExpType",
"BadgeCategory",
"ConditionType",
]

153
backend/app/models/level.py Normal file
View File

@@ -0,0 +1,153 @@
"""
等级与奖章系统模型
包含:
- UserLevel: 用户等级信息
- ExpHistory: 经验值变化历史
- BadgeDefinition: 奖章定义
- UserBadge: 用户已获得的奖章
- LevelConfig: 等级配置
"""
from datetime import datetime, date
from typing import Optional, List
from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class UserLevel(BaseModel):
"""用户等级表"""
__tablename__ = "user_levels"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
level = Column(Integer, nullable=False, default=1, comment="当前等级")
exp = Column(Integer, nullable=False, default=0, comment="当前经验值")
total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值")
login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数")
max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数")
last_login_date = Column(Date, nullable=True, comment="最后登录日期")
last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间")
# 关联
user = relationship("User", backref="user_level", uselist=False)
# 不继承 is_deleted 等软删除字段
is_deleted = None
deleted_at = None
class ExpHistory(BaseModel):
"""经验值历史表"""
__tablename__ = "exp_history"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
exp_change = Column(Integer, nullable=False, comment="经验值变化")
exp_type = Column(String(50), nullable=False, index=True, comment="类型exam/practice/training/task/login/badge/other")
source_id = Column(Integer, nullable=True, comment="来源记录ID")
description = Column(String(255), nullable=False, comment="描述")
level_before = Column(Integer, nullable=True, comment="变化前等级")
level_after = Column(Integer, nullable=True, comment="变化后等级")
# 关联
user = relationship("User", backref="exp_histories")
# 不继承软删除字段
is_deleted = None
deleted_at = None
class BadgeDefinition(BaseModel):
"""奖章定义表"""
__tablename__ = "badge_definitions"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(50), nullable=False, unique=True, comment="奖章编码")
name = Column(String(100), nullable=False, comment="奖章名称")
description = Column(String(255), nullable=False, comment="奖章描述")
icon = Column(String(100), nullable=False, default="Medal", comment="图标名称")
category = Column(String(50), nullable=False, index=True, comment="分类")
condition_type = Column(String(50), nullable=False, comment="条件类型")
condition_field = Column(String(100), nullable=True, comment="条件字段")
condition_value = Column(Integer, nullable=False, default=1, comment="条件数值")
exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值")
sort_order = Column(Integer, nullable=False, default=0, comment="排序")
is_active = Column(Boolean, nullable=False, default=True, comment="是否启用")
# 关联
user_badges = relationship("UserBadge", back_populates="badge")
# 不继承软删除字段
is_deleted = None
deleted_at = None
class UserBadge(BaseModel):
"""用户奖章表"""
__tablename__ = "user_badges"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True)
unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间")
is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知")
notified_at = Column(DateTime, nullable=True, comment="通知时间")
# 关联
user = relationship("User", backref="badges")
badge = relationship("BadgeDefinition", back_populates="user_badges")
# 不继承软删除字段
is_deleted = None
deleted_at = None
class LevelConfig(BaseModel):
"""等级配置表"""
__tablename__ = "level_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
level = Column(Integer, nullable=False, unique=True, comment="等级")
exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值")
total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值")
title = Column(String(50), nullable=False, comment="等级称号")
color = Column(String(20), nullable=True, comment="等级颜色")
# 不继承软删除字段
is_deleted = None
deleted_at = None
# 经验值类型枚举
class ExpType:
"""经验值类型"""
EXAM = "exam" # 考试
PRACTICE = "practice" # 练习
TRAINING = "training" # 陪练
TASK = "task" # 任务
LOGIN = "login" # 登录/签到
BADGE = "badge" # 奖章奖励
OTHER = "other" # 其他
# 奖章分类枚举
class BadgeCategory:
"""奖章分类"""
LEARNING = "learning" # 学习进度
EXAM = "exam" # 考试成绩
PRACTICE = "practice" # 练习时长
STREAK = "streak" # 连续打卡
SPECIAL = "special" # 特殊成就
# 条件类型枚举
class ConditionType:
"""解锁条件类型"""
COUNT = "count" # 次数
SCORE = "score" # 分数
STREAK = "streak" # 连续天数
LEVEL = "level" # 等级
DURATION = "duration" # 时长

View File

@@ -0,0 +1,467 @@
"""
奖章服务
提供奖章管理功能:
- 获取奖章定义
- 检查奖章解锁条件
- 授予奖章
- 获取用户奖章
"""
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.level import (
BadgeDefinition, UserBadge, UserLevel, ExpHistory, ExpType,
BadgeCategory, ConditionType
)
from app.models.exam import Exam
from app.models.practice import PracticeSession, PracticeReport
from app.models.training import TrainingSession, TrainingReport
from app.models.task import TaskAssignment
logger = get_logger(__name__)
class BadgeService:
"""奖章服务"""
def __init__(self, db: AsyncSession):
self.db = db
self._badge_definitions: Optional[List[BadgeDefinition]] = None
async def _get_badge_definitions(self) -> List[BadgeDefinition]:
"""获取所有奖章定义(带缓存)"""
if self._badge_definitions is None:
result = await self.db.execute(
select(BadgeDefinition)
.where(BadgeDefinition.is_active == True)
.order_by(BadgeDefinition.sort_order)
)
self._badge_definitions = list(result.scalars().all())
return self._badge_definitions
async def get_all_badges(self) -> List[Dict[str, Any]]:
"""
获取所有奖章定义
Returns:
奖章定义列表
"""
badges = await self._get_badge_definitions()
return [
{
"id": b.id,
"code": b.code,
"name": b.name,
"description": b.description,
"icon": b.icon,
"category": b.category,
"condition_type": b.condition_type,
"condition_value": b.condition_value,
"exp_reward": b.exp_reward,
}
for b in badges
]
async def get_user_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
获取用户已解锁的奖章
Args:
user_id: 用户ID
Returns:
用户奖章列表
"""
result = await self.db.execute(
select(UserBadge, BadgeDefinition)
.join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id)
.where(UserBadge.user_id == user_id)
.order_by(UserBadge.unlocked_at.desc())
)
rows = result.all()
return [
{
"id": user_badge.id,
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"category": badge.category,
"unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None,
"is_notified": user_badge.is_notified,
}
for user_badge, badge in rows
]
async def get_user_badges_with_status(self, user_id: int) -> List[Dict[str, Any]]:
"""
获取所有奖章及用户解锁状态
Args:
user_id: 用户ID
Returns:
所有奖章列表(含解锁状态)
"""
# 获取所有奖章定义
all_badges = await self._get_badge_definitions()
# 获取用户已解锁的奖章
result = await self.db.execute(
select(UserBadge).where(UserBadge.user_id == user_id)
)
user_badges = {ub.badge_id: ub for ub in result.scalars().all()}
badges_with_status = []
for badge in all_badges:
user_badge = user_badges.get(badge.id)
badges_with_status.append({
"id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"category": badge.category,
"condition_type": badge.condition_type,
"condition_value": badge.condition_value,
"exp_reward": badge.exp_reward,
"unlocked": user_badge is not None,
"unlocked_at": user_badge.unlocked_at.isoformat() if user_badge else None,
})
return badges_with_status
async def _get_user_stats(self, user_id: int) -> Dict[str, Any]:
"""
获取用户统计数据(用于检查奖章条件)
Args:
user_id: 用户ID
Returns:
统计数据字典
"""
stats = {
"login_count": 0,
"login_streak": 0,
"course_completed": 0,
"exam_passed": 0,
"exam_perfect_count": 0,
"exam_excellent": 0,
"practice_count": 0,
"practice_hours": 0,
"training_count": 0,
"first_practice_90": 0,
"user_level": 1,
}
# 获取用户等级信息
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
user_level = result.scalar_one_or_none()
if user_level:
stats["login_streak"] = user_level.login_streak
stats["user_level"] = user_level.level
# 获取登录次数(从经验值历史)
result = await self.db.execute(
select(func.count(ExpHistory.id))
.where(
ExpHistory.user_id == user_id,
ExpHistory.exp_type == ExpType.LOGIN
)
)
stats["login_count"] = result.scalar() or 0
# 获取考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.sum(func.if_(Exam.score >= 100, 1, 0)),
func.sum(func.if_(Exam.score >= 90, 1, 0))
)
.where(
Exam.user_id == user_id,
Exam.is_passed == True,
Exam.status == "submitted"
)
)
row = result.first()
if row:
stats["exam_passed"] = row[0] or 0
stats["exam_perfect_count"] = int(row[1] or 0)
stats["exam_excellent"] = int(row[2] or 0)
# 获取练习统计
result = await self.db.execute(
select(
func.count(PracticeSession.id),
func.sum(PracticeSession.duration_seconds)
)
.where(
PracticeSession.user_id == user_id,
PracticeSession.status == "completed"
)
)
row = result.first()
if row:
stats["practice_count"] = row[0] or 0
total_seconds = row[1] or 0
stats["practice_hours"] = total_seconds / 3600
# 获取陪练统计
result = await self.db.execute(
select(func.count(TrainingSession.id))
.where(
TrainingSession.user_id == user_id,
TrainingSession.status == "COMPLETED"
)
)
stats["training_count"] = result.scalar() or 0
# 检查首次高分陪练
result = await self.db.execute(
select(func.count(TrainingReport.id))
.where(
TrainingReport.user_id == user_id,
TrainingReport.overall_score >= 90
)
)
stats["first_practice_90"] = 1 if (result.scalar() or 0) > 0 else 0
return stats
async def check_and_award_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
检查并授予用户符合条件的奖章
Args:
user_id: 用户ID
Returns:
新获得的奖章列表
"""
# 获取用户统计数据
stats = await self._get_user_stats(user_id)
# 获取所有奖章定义
all_badges = await self._get_badge_definitions()
# 获取用户已有的奖章
result = await self.db.execute(
select(UserBadge.badge_id).where(UserBadge.user_id == user_id)
)
owned_badge_ids = {row[0] for row in result.all()}
# 检查每个奖章的解锁条件
newly_awarded = []
for badge in all_badges:
if badge.id in owned_badge_ids:
continue
# 检查条件
condition_met = self._check_badge_condition(badge, stats)
if condition_met:
# 授予奖章
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
unlocked_at=datetime.now(),
is_notified=False
)
self.db.add(user_badge)
# 如果有经验奖励,添加经验值
if badge.exp_reward > 0:
from app.services.level_service import LevelService
level_service = LevelService(self.db)
await level_service.add_exp(
user_id=user_id,
exp_amount=badge.exp_reward,
exp_type=ExpType.BADGE,
description=f"解锁奖章「{badge.name}"
)
newly_awarded.append({
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"exp_reward": badge.exp_reward,
})
logger.info(f"用户 {user_id} 解锁奖章: {badge.name}")
if newly_awarded:
await self.db.flush()
return newly_awarded
def _check_badge_condition(self, badge: BadgeDefinition, stats: Dict[str, Any]) -> bool:
"""
检查奖章解锁条件
Args:
badge: 奖章定义
stats: 用户统计数据
Returns:
是否满足条件
"""
condition_field = badge.condition_field
condition_value = badge.condition_value
condition_type = badge.condition_type
if not condition_field:
return False
current_value = stats.get(condition_field, 0)
if condition_type == ConditionType.COUNT:
return current_value >= condition_value
elif condition_type == ConditionType.SCORE:
return current_value >= condition_value
elif condition_type == ConditionType.STREAK:
return current_value >= condition_value
elif condition_type == ConditionType.LEVEL:
return current_value >= condition_value
elif condition_type == ConditionType.DURATION:
return current_value >= condition_value
return False
async def award_badge(self, user_id: int, badge_code: str) -> Optional[Dict[str, Any]]:
"""
直接授予用户奖章(用于特殊奖章)
Args:
user_id: 用户ID
badge_code: 奖章编码
Returns:
奖章信息(如果成功)
"""
# 获取奖章定义
result = await self.db.execute(
select(BadgeDefinition).where(BadgeDefinition.code == badge_code)
)
badge = result.scalar_one_or_none()
if not badge:
logger.warning(f"奖章不存在: {badge_code}")
return None
# 检查是否已拥有
result = await self.db.execute(
select(UserBadge).where(
UserBadge.user_id == user_id,
UserBadge.badge_id == badge.id
)
)
if result.scalar_one_or_none():
logger.info(f"用户 {user_id} 已拥有奖章: {badge_code}")
return None
# 授予奖章
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
unlocked_at=datetime.now(),
is_notified=False
)
self.db.add(user_badge)
# 添加经验值奖励
if badge.exp_reward > 0:
from app.services.level_service import LevelService
level_service = LevelService(self.db)
await level_service.add_exp(
user_id=user_id,
exp_amount=badge.exp_reward,
exp_type=ExpType.BADGE,
description=f"解锁奖章「{badge.name}"
)
await self.db.flush()
logger.info(f"用户 {user_id} 获得奖章: {badge.name}")
return {
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"exp_reward": badge.exp_reward,
}
async def get_unnotified_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
获取用户未通知的新奖章
Args:
user_id: 用户ID
Returns:
未通知的奖章列表
"""
result = await self.db.execute(
select(UserBadge, BadgeDefinition)
.join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id)
.where(
UserBadge.user_id == user_id,
UserBadge.is_notified == False
)
.order_by(UserBadge.unlocked_at.desc())
)
rows = result.all()
return [
{
"user_badge_id": user_badge.id,
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"exp_reward": badge.exp_reward,
"unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None,
}
for user_badge, badge in rows
]
async def mark_badges_notified(self, user_id: int, badge_ids: List[int] = None):
"""
标记奖章为已通知
Args:
user_id: 用户ID
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
from sqlalchemy import update
query = update(UserBadge).where(
UserBadge.user_id == user_id,
UserBadge.is_notified == False
)
if badge_ids:
query = query.where(UserBadge.badge_id.in_(badge_ids))
query = query.values(
is_notified=True,
notified_at=datetime.now()
)
await self.db.execute(query)
await self.db.flush()

View File

@@ -0,0 +1,588 @@
"""
等级服务
提供用户等级管理功能:
- 经验值获取与计算
- 等级升级判断
- 每日签到
- 排行榜查询
"""
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import select, func, and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.level import (
UserLevel, ExpHistory, LevelConfig, ExpType
)
from app.models.user import User
logger = get_logger(__name__)
# 经验值配置
EXP_CONFIG = {
# 每日签到
"login_base": 10, # 基础签到经验
"login_streak_7": 20, # 连续7天额外奖励
"login_streak_30": 50, # 连续30天额外奖励
# 考试
"exam_pass": 50, # 通过考试
"exam_excellent": 30, # 90分以上额外
"exam_perfect": 50, # 满分额外
# 练习
"practice_complete": 20, # 完成练习
"practice_good": 10, # 80分以上额外
# 陪练
"training_complete": 30, # 完成陪练
"training_good": 15, # 80分以上额外
# 任务
"task_complete": 40, # 完成任务
}
class LevelService:
"""等级服务"""
def __init__(self, db: AsyncSession):
self.db = db
self._level_configs: Optional[List[LevelConfig]] = None
async def _get_level_configs(self) -> List[LevelConfig]:
"""获取等级配置(带缓存)"""
if self._level_configs is None:
result = await self.db.execute(
select(LevelConfig).order_by(LevelConfig.level)
)
self._level_configs = list(result.scalars().all())
return self._level_configs
async def get_or_create_user_level(self, user_id: int) -> UserLevel:
"""
获取或创建用户等级记录
Args:
user_id: 用户ID
Returns:
UserLevel 对象
"""
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
user_level = result.scalar_one_or_none()
if not user_level:
user_level = UserLevel(
user_id=user_id,
level=1,
exp=0,
total_exp=0,
login_streak=0,
max_login_streak=0
)
self.db.add(user_level)
await self.db.flush()
logger.info(f"为用户 {user_id} 创建等级记录")
return user_level
async def get_user_level_info(self, user_id: int) -> Dict[str, Any]:
"""
获取用户等级详细信息
Args:
user_id: 用户ID
Returns:
包含等级、经验值、称号等信息的字典
"""
user_level = await self.get_or_create_user_level(user_id)
level_configs = await self._get_level_configs()
# 获取当前等级配置
current_config = next(
(c for c in level_configs if c.level == user_level.level),
level_configs[0] if level_configs else None
)
# 获取下一等级配置
next_config = next(
(c for c in level_configs if c.level == user_level.level + 1),
None
)
# 计算升级所需经验
exp_to_next_level = 0
next_level_total_exp = 0
if next_config:
next_level_total_exp = next_config.total_exp_required
exp_to_next_level = next_level_total_exp - user_level.total_exp
if exp_to_next_level < 0:
exp_to_next_level = 0
return {
"user_id": user_id,
"level": user_level.level,
"exp": user_level.exp,
"total_exp": user_level.total_exp,
"title": current_config.title if current_config else "初学者",
"color": current_config.color if current_config else "#909399",
"login_streak": user_level.login_streak,
"max_login_streak": user_level.max_login_streak,
"last_checkin_at": user_level.last_checkin_at.isoformat() if user_level.last_checkin_at else None,
"next_level_exp": next_level_total_exp,
"exp_to_next_level": exp_to_next_level,
"is_max_level": next_config is None,
}
async def add_exp(
self,
user_id: int,
exp_amount: int,
exp_type: str,
description: str,
source_id: Optional[int] = None
) -> Tuple[UserLevel, bool, Optional[int]]:
"""
增加用户经验值
Args:
user_id: 用户ID
exp_amount: 经验值数量
exp_type: 经验值类型
description: 描述
source_id: 来源ID可选
Returns:
(用户等级对象, 是否升级, 新等级)
"""
if exp_amount <= 0:
logger.warning(f"尝试增加非正数经验值: {exp_amount}")
return await self.get_or_create_user_level(user_id), False, None
user_level = await self.get_or_create_user_level(user_id)
level_before = user_level.level
# 增加经验值
user_level.exp += exp_amount
user_level.total_exp += exp_amount
# 检查是否升级
level_configs = await self._get_level_configs()
leveled_up = False
new_level = None
for config in level_configs:
if config.level > user_level.level and user_level.total_exp >= config.total_exp_required:
user_level.level = config.level
leveled_up = True
new_level = config.level
# 记录经验值历史
exp_history = ExpHistory(
user_id=user_id,
exp_change=exp_amount,
exp_type=exp_type,
source_id=source_id,
description=description,
level_before=level_before,
level_after=user_level.level
)
self.db.add(exp_history)
await self.db.flush()
if leveled_up:
logger.info(f"用户 {user_id} 升级: {level_before} -> {new_level}")
logger.info(f"用户 {user_id} 获得 {exp_amount} 经验值: {description}")
return user_level, leveled_up, new_level
async def daily_checkin(self, user_id: int) -> Dict[str, Any]:
"""
每日签到
Args:
user_id: 用户ID
Returns:
签到结果
"""
user_level = await self.get_or_create_user_level(user_id)
today = date.today()
# 检查今天是否已签到
if user_level.last_login_date == today:
return {
"success": False,
"message": "今天已经签到过了",
"exp_gained": 0,
"login_streak": user_level.login_streak,
"already_checked_in": True
}
# 计算连续登录
yesterday = today - timedelta(days=1)
if user_level.last_login_date == yesterday:
# 连续登录
user_level.login_streak += 1
else:
# 中断了,重新计算
user_level.login_streak = 1
# 更新最长连续登录记录
if user_level.login_streak > user_level.max_login_streak:
user_level.max_login_streak = user_level.login_streak
user_level.last_login_date = today
user_level.last_checkin_at = datetime.now()
# 计算签到经验
exp_gained = EXP_CONFIG["login_base"]
bonus_exp = 0
bonus_reason = []
# 连续登录奖励
if user_level.login_streak >= 30 and user_level.login_streak % 30 == 0:
bonus_exp += EXP_CONFIG["login_streak_30"]
bonus_reason.append(f"连续{user_level.login_streak}")
elif user_level.login_streak >= 7 and user_level.login_streak % 7 == 0:
bonus_exp += EXP_CONFIG["login_streak_7"]
bonus_reason.append(f"连续{user_level.login_streak}")
total_exp = exp_gained + bonus_exp
# 添加经验值
description = f"每日签到"
if bonus_reason:
description += f"{', '.join(bonus_reason)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=total_exp,
exp_type=ExpType.LOGIN,
description=description
)
await self.db.commit()
return {
"success": True,
"message": "签到成功",
"exp_gained": total_exp,
"base_exp": exp_gained,
"bonus_exp": bonus_exp,
"login_streak": user_level.login_streak,
"leveled_up": leveled_up,
"new_level": new_level,
"already_checked_in": False
}
async def get_exp_history(
self,
user_id: int,
limit: int = 50,
offset: int = 0,
exp_type: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""
获取经验值历史
Args:
user_id: 用户ID
limit: 限制数量
offset: 偏移量
exp_type: 类型筛选
Returns:
(历史记录列表, 总数)
"""
# 构建查询
query = select(ExpHistory).where(ExpHistory.user_id == user_id)
if exp_type:
query = query.where(ExpHistory.exp_type == exp_type)
# 获取总数
count_query = select(func.count(ExpHistory.id)).where(ExpHistory.user_id == user_id)
if exp_type:
count_query = count_query.where(ExpHistory.exp_type == exp_type)
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
# 获取记录
query = query.order_by(desc(ExpHistory.created_at)).limit(limit).offset(offset)
result = await self.db.execute(query)
records = result.scalars().all()
history = [
{
"id": r.id,
"exp_change": r.exp_change,
"exp_type": r.exp_type,
"description": r.description,
"level_before": r.level_before,
"level_after": r.level_after,
"created_at": r.created_at.isoformat() if r.created_at else None
}
for r in records
]
return history, total
async def get_leaderboard(
self,
limit: int = 50,
offset: int = 0
) -> Tuple[List[Dict[str, Any]], int]:
"""
获取等级排行榜
Args:
limit: 限制数量
offset: 偏移量
Returns:
(排行榜列表, 总数)
"""
# 获取总数
count_query = select(func.count(UserLevel.id))
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
# 获取排行榜(按等级和总经验值排序)
query = (
select(UserLevel, User)
.join(User, UserLevel.user_id == User.id)
.where(User.is_deleted == False)
.order_by(desc(UserLevel.level), desc(UserLevel.total_exp))
.limit(limit)
.offset(offset)
)
result = await self.db.execute(query)
rows = result.all()
# 获取等级配置
level_configs = await self._get_level_configs()
config_map = {c.level: c for c in level_configs}
leaderboard = []
for i, (user_level, user) in enumerate(rows):
config = config_map.get(user_level.level)
leaderboard.append({
"rank": offset + i + 1,
"user_id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"level": user_level.level,
"title": config.title if config else "初学者",
"color": config.color if config else "#909399",
"total_exp": user_level.total_exp,
"login_streak": user_level.login_streak,
})
return leaderboard, total
async def get_user_rank(self, user_id: int) -> Optional[int]:
"""
获取用户排名
Args:
user_id: 用户ID
Returns:
排名从1开始
"""
user_level = await self.get_or_create_user_level(user_id)
# 计算排在该用户前面的人数
query = select(func.count(UserLevel.id)).where(
and_(
UserLevel.user_id != user_id,
(
(UserLevel.level > user_level.level) |
(
(UserLevel.level == user_level.level) &
(UserLevel.total_exp > user_level.total_exp)
)
)
)
)
result = await self.db.execute(query)
count = result.scalar() or 0
return count + 1
async def add_exam_exp(
self,
user_id: int,
exam_id: int,
score: float,
is_passed: bool
) -> Optional[Dict[str, Any]]:
"""
考试通过获得经验值
Args:
user_id: 用户ID
exam_id: 考试ID
score: 得分
is_passed: 是否通过
Returns:
经验值变化信息
"""
if not is_passed:
return None
exp_gained = EXP_CONFIG["exam_pass"]
bonus = []
if score >= 100:
exp_gained += EXP_CONFIG["exam_perfect"]
bonus.append("满分")
elif score >= 90:
exp_gained += EXP_CONFIG["exam_excellent"]
bonus.append("优秀")
description = f"通过考试"
if bonus:
description += f"{', '.join(bonus)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.EXAM,
description=description,
source_id=exam_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}
async def add_practice_exp(
self,
user_id: int,
session_id: int,
score: Optional[float] = None
) -> Dict[str, Any]:
"""
完成练习获得经验值
Args:
user_id: 用户ID
session_id: 练习会话ID
score: 得分(可选)
Returns:
经验值变化信息
"""
exp_gained = EXP_CONFIG["practice_complete"]
bonus = []
if score is not None and score >= 80:
exp_gained += EXP_CONFIG["practice_good"]
bonus.append("高分")
description = f"完成练习"
if bonus:
description += f"{', '.join(bonus)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.PRACTICE,
description=description,
source_id=session_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}
async def add_training_exp(
self,
user_id: int,
session_id: int,
score: Optional[float] = None
) -> Dict[str, Any]:
"""
完成陪练获得经验值
Args:
user_id: 用户ID
session_id: 陪练会话ID
score: 得分(可选)
Returns:
经验值变化信息
"""
exp_gained = EXP_CONFIG["training_complete"]
bonus = []
if score is not None and score >= 80:
exp_gained += EXP_CONFIG["training_good"]
bonus.append("高分")
description = f"完成陪练"
if bonus:
description += f"{', '.join(bonus)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.TRAINING,
description=description,
source_id=session_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}
async def add_task_exp(
self,
user_id: int,
task_id: int
) -> Dict[str, Any]:
"""
完成任务获得经验值
Args:
user_id: 用户ID
task_id: 任务ID
Returns:
经验值变化信息
"""
exp_gained = EXP_CONFIG["task_complete"]
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.TASK,
description="完成任务",
source_id=task_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}

View File

@@ -1,82 +1,82 @@
# 数据库迁移说明
## 目录结构
```
migrations/
├── README.md # 本说明文件
└── versions/
└── add_practice_rooms_table.sql # 双人对练功能迁移脚本
```
本目录包含 KPL 考培练系统的数据库迁移脚本。
## 迁移脚本列表
### 1. add_practice_rooms_table.sql
| 脚本 | 说明 | 创建时间 |
|------|------|----------|
| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 |
**功能**:双人对练功能数据库结构
## 执行迁移
**新增表**
- `practice_rooms` - 对练房间表
- `practice_room_messages` - 房间实时消息表
### 测试环境Docker
**修改表**
- `practice_dialogues` - 新增 `user_id`, `role_name`, `room_id`, `message_type` 字段
- `practice_sessions` - 新增 `room_id`, `participant_role`, `session_type` 字段
- `practice_reports` - 新增 `room_id`, `user_id`, `report_type`, `partner_feedback`, `interaction_score` 字段
## 执行方法
### 方法一:直接登录 MySQL 执行
KPL 测试环境数据库在服务器 Docker 容器中运行
```bash
# 1. 登录 MySQL
docker exec -it kpl-mysql-dev mysql -uroot -p
# 1. SSH 登录 KPL 服务器
ssh root@<KPL服务器IP>
# 2. 选择数据库
USE kaopeilian;
# 2. 进入项目目录
cd /www/wwwroot/kpl.ireborn.com.cn
# 3. 复制并执行 SQL 脚本内容
# 或使用 source 命令执行脚本文件
# 3. 执行迁移(方法一:直接执行)
docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql
# 或者(方法二:交互式执行)
docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev
# 然后复制粘贴 SQL 脚本内容执行
# 方法三从本地执行需要先上传SQL文件到服务器
# scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/
# ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql"
```
### 方法二:使用 docker exec 执行
**注意**MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认)
### 生产环境
生产环境迁移前请确保:
1. 已备份数据库
2. 在低峰期执行
3. 测试环境验证通过
```bash
# 1. 将 SQL 文件复制到容器
docker cp migrations/versions/add_practice_rooms_table.sql kpl-mysql-dev:/tmp/
# 2. 执行迁移
docker exec -i kpl-mysql-dev mysql -uroot -pYourPassword kaopeilian < /tmp/add_practice_rooms_table.sql
```
### 方法三:远程 SSH 执行
```bash
# SSH 到服务器后执行
ssh user@your-server "docker exec -i kpl-mysql-dev mysql -uroot -pYourPassword kaopeilian" < migrations/versions/add_practice_rooms_table.sql
# 执行迁移(替换为实际的生产数据库配置)
mysql -h<host> -u<user> -p<password> <database> < backend/migrations/add_level_badge_system.sql
```
## 回滚方法
每个迁移脚本底部都包含了回滚 SQL。如需回滚取消注释并执行回滚部分的 SQL 即可。
如需回滚,执行以下 SQL
## 生产环境迁移检查清单
```sql
DROP TABLE IF EXISTS user_badges;
DROP TABLE IF EXISTS badge_definitions;
DROP TABLE IF EXISTS exp_history;
DROP TABLE IF EXISTS level_configs;
DROP TABLE IF EXISTS user_levels;
```
- [ ] 备份数据库
- [ ] 在测试环境验证迁移脚本
- [ ] 检查是否有正在进行的事务
- [ ] 执行迁移脚本
- [ ] 验证表结构是否正确
- [ ] 验证索引是否创建成功
- [ ] 重启后端服务(如有必要)
- [ ] 验证功能是否正常
## 验证迁移
## 注意事项
执行以下查询验证表是否创建成功:
1. **MySQL 版本兼容性**:脚本使用了 `IF NOT EXISTS``IF EXISTS`,确保 MySQL 版本支持这些语法8.0+ 完全支持)
```sql
SHOW TABLES LIKE '%level%';
SHOW TABLES LIKE '%badge%';
SHOW TABLES LIKE '%exp%';
2. **字符集**:表默认使用 `utf8mb4` 字符集,支持表情符号等特殊字符
-- 查看表结构
DESCRIBE user_levels;
DESCRIBE exp_history;
DESCRIBE badge_definitions;
DESCRIBE user_badges;
DESCRIBE level_configs;
3. **外键约束**:脚本中的外键约束默认被注释,根据实际需求决定是否启用
4. **索引优化**:已为常用查询字段创建索引,如需调整请根据实际查询模式优化
-- 验证初始数据
SELECT COUNT(*) FROM level_configs; -- 应该是 10 条
SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条
SELECT COUNT(*) FROM user_levels; -- 应该等于用户数
```

View File

@@ -0,0 +1,192 @@
-- =====================================================
-- 等级与奖章系统数据库迁移脚本
-- 版本: 1.0.0
-- 创建时间: 2026-01-29
-- 说明: 添加用户等级系统和奖章系统相关表
-- =====================================================
-- 使用事务确保原子性
START TRANSACTION;
-- =====================================================
-- 1. 用户等级表 (user_levels)
-- 存储用户的等级和经验值信息
-- =====================================================
CREATE TABLE IF NOT EXISTS user_levels (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID',
level INT NOT NULL DEFAULT 1 COMMENT '当前等级',
exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值',
total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值',
login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数',
max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数',
last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)',
last_checkin_at DATETIME NULL COMMENT '最后签到时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_id (user_id),
INDEX idx_level (level),
INDEX idx_total_exp (total_exp),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表';
-- =====================================================
-- 2. 经验值历史表 (exp_history)
-- 记录每次经验值变化的详细信息
-- =====================================================
CREATE TABLE IF NOT EXISTS exp_history (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID',
exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)',
exp_type VARCHAR(50) NOT NULL COMMENT '类型exam/practice/training/task/login/badge/other',
source_id INT NULL COMMENT '来源记录ID如考试ID、练习ID等',
description VARCHAR(255) NOT NULL COMMENT '描述',
level_before INT NULL COMMENT '变化前等级',
level_after INT NULL COMMENT '变化后等级',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_exp_type (exp_type),
INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表';
-- =====================================================
-- 3. 奖章定义表 (badge_definitions)
-- 定义所有可获得的奖章及其解锁条件
-- =====================================================
CREATE TABLE IF NOT EXISTS badge_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)',
name VARCHAR(100) NOT NULL COMMENT '奖章名称',
description VARCHAR(255) NOT NULL COMMENT '奖章描述',
icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称Element Plus 图标)',
category VARCHAR(50) NOT NULL COMMENT '分类learning/exam/practice/streak/special',
condition_type VARCHAR(50) NOT NULL COMMENT '条件类型count/score/streak/level/duration',
condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)',
condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值',
exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code),
INDEX idx_category (category),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表';
-- =====================================================
-- 4. 用户奖章表 (user_badges)
-- 记录用户已解锁的奖章
-- =====================================================
CREATE TABLE IF NOT EXISTS user_badges (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID',
badge_id INT NOT NULL COMMENT '奖章ID',
unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间',
is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户',
notified_at DATETIME NULL COMMENT '通知时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_badge (user_id, badge_id),
INDEX idx_user_id (user_id),
INDEX idx_badge_id (badge_id),
INDEX idx_unlocked_at (unlocked_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表';
-- =====================================================
-- 5. 等级配置表 (level_configs)
-- 定义每个等级所需的经验值和称号
-- =====================================================
CREATE TABLE IF NOT EXISTS level_configs (
id INT AUTO_INCREMENT PRIMARY KEY,
level INT NOT NULL COMMENT '等级',
exp_required INT NOT NULL COMMENT '升到此级所需经验值',
total_exp_required INT NOT NULL COMMENT '累计所需经验值',
title VARCHAR(50) NOT NULL COMMENT '等级称号',
color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_level (level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表';
-- =====================================================
-- 6. 插入等级配置数据
-- =====================================================
INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES
(1, 0, 0, '初学者', '#909399'),
(2, 100, 100, '入门学徒', '#67C23A'),
(3, 200, 300, '勤奋学员', '#67C23A'),
(4, 400, 700, '进阶学员', '#409EFF'),
(5, 600, 1300, '优秀学员', '#409EFF'),
(6, 1000, 2300, '精英学员', '#E6A23C'),
(7, 1500, 3800, '资深学员', '#E6A23C'),
(8, 2000, 5800, '学习达人', '#F56C6C'),
(9, 3000, 8800, '学霸', '#F56C6C'),
(10, 5000, 13800, '大师', '#9B59B6');
-- =====================================================
-- 7. 插入奖章定义数据
-- =====================================================
-- 7.1 学习进度类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101),
('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102),
('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103),
('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104);
-- 7.2 考试成绩类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201),
('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202),
('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203),
('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204),
('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205);
-- 7.3 练习时长类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301),
('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302),
('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303),
('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304);
-- 7.4 连续打卡类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401),
('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402),
('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403),
('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404);
-- 7.5 特殊成就类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501),
('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502),
('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503),
('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504);
-- =====================================================
-- 8. 为现有用户初始化等级数据
-- =====================================================
INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date)
SELECT
id as user_id,
1 as level,
0 as exp,
0 as total_exp,
0 as login_streak,
NULL as last_login_date
FROM users
WHERE is_deleted = 0
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP;
-- 提交事务
COMMIT;
-- =====================================================
-- 回滚脚本(如需回滚,执行以下语句)
-- =====================================================
-- DROP TABLE IF EXISTS user_badges;
-- DROP TABLE IF EXISTS badge_definitions;
-- DROP TABLE IF EXISTS exp_history;
-- DROP TABLE IF EXISTS level_configs;
-- DROP TABLE IF EXISTS user_levels;

182
frontend/src/api/level.ts Normal file
View File

@@ -0,0 +1,182 @@
/**
* 等级与奖章 API
*/
import request from '@/api/request'
// 类型定义
export interface LevelInfo {
user_id: number
level: number
exp: number
total_exp: number
title: string
color: string
login_streak: number
max_login_streak: number
last_checkin_at: string | null
next_level_exp: number
exp_to_next_level: number
is_max_level: boolean
}
export interface ExpHistoryItem {
id: number
exp_change: number
exp_type: string
description: string
level_before: number | null
level_after: number | null
created_at: string
}
export interface LeaderboardItem {
rank: number
user_id: number
username: string
full_name: string | null
avatar_url: string | null
level: number
title: string
color: string
total_exp: number
login_streak: number
}
export interface Badge {
id: number
code: string
name: string
description: string
icon: string
category: string
condition_type?: string
condition_value?: number
exp_reward: number
unlocked?: boolean
unlocked_at?: string | null
}
export interface CheckinResult {
success: boolean
message: string
exp_gained: number
base_exp?: number
bonus_exp?: number
login_streak: number
leveled_up?: boolean
new_level?: number | null
already_checked_in?: boolean
new_badges?: Badge[]
}
// API 函数
/**
* 获取当前用户等级信息
*/
export function getMyLevel() {
return request.get<LevelInfo>('/level/me')
}
/**
* 获取指定用户等级信息
*/
export function getUserLevel(userId: number) {
return request.get<LevelInfo>(`/level/user/${userId}`)
}
/**
* 每日签到
*/
export function dailyCheckin() {
return request.post<CheckinResult>('/level/checkin')
}
/**
* 获取经验值历史
*/
export function getExpHistory(params?: {
limit?: number
offset?: number
exp_type?: string
}) {
return request.get<{
items: ExpHistoryItem[]
total: number
limit: number
offset: number
}>('/level/exp-history', { params })
}
/**
* 获取等级排行榜
*/
export function getLeaderboard(params?: {
limit?: number
offset?: number
}) {
return request.get<{
items: LeaderboardItem[]
total: number
limit: number
offset: number
my_rank: number
my_level_info: LevelInfo
}>('/level/leaderboard', { params })
}
/**
* 获取所有奖章定义
*/
export function getAllBadges() {
return request.get<Badge[]>('/level/badges/all')
}
/**
* 获取用户奖章(含解锁状态)
*/
export function getMyBadges() {
return request.get<{
badges: Badge[]
total: number
unlocked_count: number
}>('/level/badges/me')
}
/**
* 获取未通知的新奖章
*/
export function getUnnotifiedBadges() {
return request.get<Badge[]>('/level/badges/unnotified')
}
/**
* 标记奖章为已通知
*/
export function markBadgesNotified(badgeIds?: number[]) {
return request.post('/level/badges/mark-notified', badgeIds)
}
/**
* 手动检查并授予奖章
*/
export function checkAndAwardBadges() {
return request.post<{
new_badges: Badge[]
count: number
}>('/level/check-badges')
}
export default {
getMyLevel,
getUserLevel,
dailyCheckin,
getExpHistory,
getLeaderboard,
getAllBadges,
getMyBadges,
getUnnotifiedBadges,
markBadgesNotified,
checkAndAwardBadges
}

View File

@@ -0,0 +1,174 @@
<template>
<div
class="badge-card"
:class="{ unlocked, locked: !unlocked }"
@click="handleClick"
>
<div class="badge-icon">
<el-icon :size="iconSize">
<component :is="iconComponent" />
</el-icon>
</div>
<div class="badge-info">
<div class="badge-name">{{ name }}</div>
<div class="badge-desc">{{ description }}</div>
<div class="badge-reward" v-if="expReward > 0 && !unlocked">
+{{ expReward }} 经验
</div>
<div class="badge-unlock-time" v-if="unlocked && unlockedAt">
{{ formatDate(unlockedAt) }}解锁
</div>
</div>
<div class="badge-status" v-if="!unlocked">
<el-icon><Lock /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Lock, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, TrendCharts, Clock,
Timer, Stopwatch, Operation, Calendar, Rank,
Crown, Headset, StarFilled
} from '@element-plus/icons-vue'
interface Props {
code: string
name: string
description: string
icon: string
category: string
expReward?: number
unlocked?: boolean
unlockedAt?: string | null
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
expReward: 0,
unlocked: false,
unlockedAt: null,
size: 'medium'
})
const emit = defineEmits<{
(e: 'click', badge: Props): void
}>()
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank, Crown,
Headset, StarFilled, Lock
}
const iconComponent = computed(() => {
return iconMap[props.icon] || Medal
})
const sizeMap = {
small: 24,
medium: 32,
large: 48
}
const iconSize = computed(() => sizeMap[props.size])
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
const handleClick = () => {
emit('click', props)
}
</script>
<style scoped lang="scss">
.badge-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background-color: #fff;
border: 1px solid #EBEEF5;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.unlocked {
.badge-icon {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
}
}
&.locked {
opacity: 0.6;
.badge-icon {
background-color: #F5F7FA;
color: #C0C4CC;
}
.badge-name {
color: #909399;
}
}
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.badge-info {
flex: 1;
min-width: 0;
.badge-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.badge-desc {
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
margin-top: 4px;
}
.badge-unlock-time {
font-size: 12px;
color: #67C23A;
margin-top: 4px;
}
}
.badge-status {
color: #C0C4CC;
font-size: 20px;
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="exp-progress">
<div class="progress-header">
<span class="label">经验值</span>
<span class="value">{{ currentExp }} / {{ targetExp }}</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: progressPercent + '%', backgroundColor: color }"
></div>
</div>
<div class="progress-footer" v-if="showFooter">
<span class="exp-to-next">距下一级还需 {{ expToNext }} 经验</span>
<span class="total-exp" v-if="showTotal">累计 {{ totalExp }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
currentExp: number
targetExp: number
totalExp?: number
color?: string
showFooter?: boolean
showTotal?: boolean
}
const props = withDefaults(defineProps<Props>(), {
totalExp: 0,
color: '#409EFF',
showFooter: true,
showTotal: false
})
const progressPercent = computed(() => {
if (props.targetExp <= 0) return 100
const percent = (props.currentExp / props.targetExp) * 100
return Math.min(percent, 100)
})
const expToNext = computed(() => {
return Math.max(0, props.targetExp - props.currentExp)
})
</script>
<style scoped lang="scss">
.exp-progress {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.label {
font-size: 14px;
color: #606266;
}
.value {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.progress-bar {
height: 8px;
background-color: #EBEEF5;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
}
.progress-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
.exp-to-next {
font-size: 12px;
color: #909399;
}
.total-exp {
font-size: 12px;
color: #909399;
}
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="level-badge" :style="{ '--level-color': color }">
<div class="level-icon">
<span class="level-number">{{ level }}</span>
</div>
<div class="level-info" v-if="showInfo">
<span class="level-title">{{ title }}</span>
<span class="level-exp" v-if="showExp">{{ exp }}/{{ nextLevelExp }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
level: number
title?: string
color?: string
exp?: number
nextLevelExp?: number
showInfo?: boolean
showExp?: boolean
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
title: '初学者',
color: '#909399',
exp: 0,
nextLevelExp: 1000,
showInfo: true,
showExp: false,
size: 'medium'
})
const sizeMap = {
small: { icon: 24, font: 12 },
medium: { icon: 32, font: 14 },
large: { icon: 48, font: 18 }
}
</script>
<style scoped lang="scss">
.level-badge {
display: inline-flex;
align-items: center;
gap: 8px;
.level-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--level-color) 0%, color-mix(in srgb, var(--level-color) 80%, #000) 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.level-number {
color: #fff;
font-size: 14px;
font-weight: 700;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
}
.level-info {
display: flex;
flex-direction: column;
gap: 2px;
.level-title {
font-size: 14px;
font-weight: 600;
color: var(--level-color);
}
.level-exp {
font-size: 12px;
color: #909399;
}
}
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<el-dialog
v-model="visible"
:show-close="false"
:close-on-click-modal="false"
width="360px"
center
class="level-up-dialog"
>
<div class="dialog-content">
<!-- 等级升级 -->
<div class="level-up-section" v-if="leveledUp">
<div class="celebration">
<div class="firework"></div>
<div class="firework"></div>
<div class="firework"></div>
</div>
<div class="level-badge-large">
<span class="level-number">{{ newLevel }}</span>
</div>
<div class="congrats-text">恭喜升级!</div>
<div class="level-title" :style="{ color: levelColor }">{{ levelTitle }}</div>
</div>
<!-- 经验值获得 -->
<div class="exp-section" v-if="expGained > 0 && !leveledUp">
<el-icon class="exp-icon" :size="48"><TrendCharts /></el-icon>
<div class="exp-text">经验值 +{{ expGained }}</div>
</div>
<!-- 新获得奖章 -->
<div class="badges-section" v-if="newBadges && newBadges.length > 0">
<div class="section-title">新解锁奖章</div>
<div class="badges-list">
<div
class="badge-item"
v-for="badge in newBadges"
:key="badge.code"
>
<div class="badge-icon">
<el-icon :size="24">
<component :is="getIconComponent(badge.icon)" />
</el-icon>
</div>
<div class="badge-name">{{ badge.name }}</div>
<div class="badge-reward" v-if="badge.exp_reward > 0">
+{{ badge.exp_reward }}
</div>
</div>
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="handleClose" size="large" round>
太棒了!
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
TrendCharts, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, Clock, Timer, Stopwatch,
Operation, Calendar, Rank, Crown, Headset, StarFilled
} from '@element-plus/icons-vue'
interface Badge {
code: string
name: string
icon: string
exp_reward?: number
}
interface Props {
modelValue: boolean
leveledUp?: boolean
newLevel?: number | null
levelTitle?: string
levelColor?: string
expGained?: number
newBadges?: Badge[]
}
const props = withDefaults(defineProps<Props>(), {
leveledUp: false,
newLevel: null,
levelTitle: '',
levelColor: '#409EFF',
expGained: 0,
newBadges: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank, Crown,
Headset, StarFilled
}
const getIconComponent = (icon: string) => {
return iconMap[icon] || Medal
}
const handleClose = () => {
visible.value = false
emit('close')
}
</script>
<style scoped lang="scss">
.level-up-dialog {
:deep(.el-dialog__header) {
display: none;
}
:deep(.el-dialog__body) {
padding: 24px;
}
}
.dialog-content {
text-align: center;
.level-up-section {
position: relative;
padding: 20px 0;
.celebration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
.firework {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
animation: firework 1s ease-out infinite;
&:nth-child(1) {
left: 20%;
top: 30%;
background-color: #FFD700;
animation-delay: 0s;
}
&:nth-child(2) {
left: 50%;
top: 20%;
background-color: #FF6B6B;
animation-delay: 0.3s;
}
&:nth-child(3) {
left: 80%;
top: 40%;
background-color: #4ECDC4;
animation-delay: 0.6s;
}
}
}
.level-badge-large {
width: 80px;
height: 80px;
margin: 0 auto 16px;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(255, 165, 0, 0.4);
animation: bounce 0.6s ease;
.level-number {
font-size: 36px;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
}
.congrats-text {
font-size: 24px;
font-weight: 700;
color: #303133;
margin-bottom: 8px;
}
.level-title {
font-size: 18px;
font-weight: 600;
}
}
.exp-section {
padding: 24px 0;
.exp-icon {
color: #E6A23C;
margin-bottom: 12px;
}
.exp-text {
font-size: 24px;
font-weight: 700;
color: #E6A23C;
}
}
.badges-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #EBEEF5;
.section-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.badges-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
.badge-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.badge-name {
font-size: 12px;
color: #303133;
font-weight: 500;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
}
}
}
}
}
@keyframes firework {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(20);
opacity: 0;
}
}
@keyframes bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
</style>

View File

@@ -31,6 +31,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/trainee/growth-path.vue'),
meta: { title: '我的成长路径', icon: 'TrendCharts' }
},
{
path: 'leaderboard',
name: 'Leaderboard',
component: () => import('@/views/trainee/leaderboard.vue'),
meta: { title: '等级排行榜', icon: 'Trophy' }
},
{
path: 'course-center',
name: 'CourseCenter',

View File

@@ -877,12 +877,26 @@ const fetchUserInfo = async () => {
userInfo.value = {
name: user.full_name || user.username || '未命名',
position: user.position_name || (user.role === 'admin' ? '管理员' : user.role === 'trainer' ? '培训师' : '学员'),
level: 1, // TODO: 从用户统计数据获取
exp: 0, // TODO: 从用户统计数据获取
nextLevelExp: 1000, // TODO: 从用户统计数据获取
level: 1,
exp: 0,
nextLevelExp: 1000,
avatar: user.avatar_url || '',
role: user.role || 'trainee', // 保存用户角色
phone: user.phone || '' // 保存用户手机号
role: user.role || 'trainee',
phone: user.phone || ''
}
// 获取等级信息
try {
const { getMyLevel } = await import('@/api/level')
const levelResponse = await getMyLevel()
if (levelResponse.code === 200 && levelResponse.data) {
const levelData = levelResponse.data
userInfo.value.level = levelData.level
userInfo.value.exp = levelData.total_exp
userInfo.value.nextLevelExp = levelData.next_level_exp || 1000
}
} catch (levelError) {
console.warn('获取等级信息失败:', levelError)
}
}
} catch (error) {

View File

@@ -0,0 +1,491 @@
<template>
<div class="leaderboard-page">
<!-- 页面标题 -->
<div class="page-header">
<h2>等级排行榜</h2>
</div>
<!-- 我的排名卡片 -->
<div class="my-rank-card" v-if="myRank">
<div class="rank-badge">
<span class="rank-number">{{ myRank }}</span>
<span class="rank-label">我的排名</span>
</div>
<div class="my-info">
<div class="level-badge">
<span class="level-number">{{ myLevelInfo?.level || 1 }}</span>
</div>
<div class="my-details">
<div class="my-title">{{ myLevelInfo?.title || '初学者' }}</div>
<div class="my-exp">累计经验: {{ myLevelInfo?.total_exp || 0 }}</div>
</div>
</div>
<div class="checkin-section">
<el-button
type="primary"
:loading="checkinLoading"
:disabled="todayCheckedIn"
@click="handleCheckin"
>
{{ todayCheckedIn ? '今日已签' : '签到 +10' }}
</el-button>
<div class="streak-info" v-if="myLevelInfo?.login_streak">
已连续签到 {{ myLevelInfo.login_streak }}
</div>
</div>
</div>
<!-- 排行榜列表 -->
<div class="leaderboard-list" v-loading="loading">
<div
class="leaderboard-item"
v-for="(item, index) in leaderboard"
:key="item.user_id"
:class="{ 'is-me': item.user_id === currentUserId }"
>
<div class="rank-section">
<div class="rank-icon" :class="getRankClass(item.rank)">
<el-icon v-if="item.rank <= 3"><Trophy /></el-icon>
<span v-else>{{ item.rank }}</span>
</div>
</div>
<div class="user-section">
<el-avatar :size="40" :src="item.avatar_url">
{{ (item.full_name || item.username || '').charAt(0) }}
</el-avatar>
<div class="user-info">
<div class="user-name">{{ item.full_name || item.username }}</div>
<div class="user-title" :style="{ color: item.color }">
Lv.{{ item.level }} {{ item.title }}
</div>
</div>
</div>
<div class="stats-section">
<div class="stat-item">
<span class="stat-value">{{ item.total_exp }}</span>
<span class="stat-label">经验值</span>
</div>
<div class="stat-item" v-if="item.login_streak > 0">
<span class="stat-value">{{ item.login_streak }}</span>
<span class="stat-label">连续登录</span>
</div>
</div>
</div>
<el-empty v-if="!loading && leaderboard.length === 0" description="暂无排行数据" />
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore">
<el-button text @click="loadMore" :loading="loadingMore">
加载更多
</el-button>
</div>
<!-- 升级/奖章弹窗 -->
<LevelUpDialog
v-model="showLevelUpDialog"
:leveled-up="levelUpResult.leveledUp"
:new-level="levelUpResult.newLevel"
:level-title="levelUpResult.levelTitle"
:level-color="levelUpResult.levelColor"
:exp-gained="levelUpResult.expGained"
:new-badges="levelUpResult.newBadges"
@close="handleDialogClose"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { Trophy } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getLeaderboard, dailyCheckin, type LeaderboardItem, type LevelInfo, type Badge } from '@/api/level'
import LevelUpDialog from '@/components/LevelUpDialog.vue'
import { authManager } from '@/utils/auth'
// 状态
const loading = ref(false)
const loadingMore = ref(false)
const checkinLoading = ref(false)
const leaderboard = ref<LeaderboardItem[]>([])
const myRank = ref<number | null>(null)
const myLevelInfo = ref<LevelInfo | null>(null)
const total = ref(0)
const pageSize = 20
const currentOffset = ref(0)
const todayCheckedIn = ref(false)
// 升级弹窗状态
const showLevelUpDialog = ref(false)
const levelUpResult = ref({
leveledUp: false,
newLevel: null as number | null,
levelTitle: '',
levelColor: '#409EFF',
expGained: 0,
newBadges: [] as Badge[]
})
// 计算属性
const currentUserId = computed(() => {
const user = authManager.getCurrentUser()
return user?.id || 0
})
const hasMore = computed(() => {
return currentOffset.value + pageSize < total.value
})
// 方法
const getRankClass = (rank: number) => {
if (rank === 1) return 'gold'
if (rank === 2) return 'silver'
if (rank === 3) return 'bronze'
return ''
}
const fetchLeaderboard = async (append = false) => {
if (!append) {
loading.value = true
currentOffset.value = 0
} else {
loadingMore.value = true
}
try {
const response = await getLeaderboard({
limit: pageSize,
offset: currentOffset.value
})
if (response.code === 200 && response.data) {
if (append) {
leaderboard.value.push(...response.data.items)
} else {
leaderboard.value = response.data.items
}
total.value = response.data.total
myRank.value = response.data.my_rank
myLevelInfo.value = response.data.my_level_info
// 检查今日是否已签到
if (myLevelInfo.value?.last_checkin_at) {
const lastCheckin = new Date(myLevelInfo.value.last_checkin_at)
const today = new Date()
todayCheckedIn.value = lastCheckin.toDateString() === today.toDateString()
}
currentOffset.value = currentOffset.value + pageSize
}
} catch (error) {
console.error('获取排行榜失败:', error)
ElMessage.error('获取排行榜失败')
} finally {
loading.value = false
loadingMore.value = false
}
}
const loadMore = () => {
fetchLeaderboard(true)
}
const handleCheckin = async () => {
if (todayCheckedIn.value) return
checkinLoading.value = true
try {
const response = await dailyCheckin()
if (response.code === 200 && response.data) {
const result = response.data
if (result.already_checked_in) {
todayCheckedIn.value = true
ElMessage.info('今天已经签到过了')
return
}
todayCheckedIn.value = true
// 更新本地数据
if (myLevelInfo.value) {
myLevelInfo.value.login_streak = result.login_streak
}
// 显示结果弹窗
levelUpResult.value = {
leveledUp: result.leveled_up || false,
newLevel: result.new_level || null,
levelTitle: '', // 后端可以返回
levelColor: '#409EFF',
expGained: result.exp_gained,
newBadges: result.new_badges || []
}
showLevelUpDialog.value = true
// 刷新排行榜数据
fetchLeaderboard()
}
} catch (error) {
console.error('签到失败:', error)
ElMessage.error('签到失败,请稍后重试')
} finally {
checkinLoading.value = false
}
}
const handleDialogClose = () => {
showLevelUpDialog.value = false
}
// 生命周期
onMounted(() => {
fetchLeaderboard()
})
</script>
<style scoped lang="scss">
.leaderboard-page {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
h2 {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
}
.my-rank-card {
display: flex;
align-items: center;
gap: 24px;
padding: 20px 24px;
background: linear-gradient(135deg, #409EFF 0%, #79bbff 100%);
border-radius: 12px;
color: #fff;
margin-bottom: 24px;
.rank-badge {
display: flex;
flex-direction: column;
align-items: center;
.rank-number {
font-size: 36px;
font-weight: 700;
}
.rank-label {
font-size: 12px;
opacity: 0.8;
}
}
.my-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.level-badge {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
.level-number {
font-size: 20px;
font-weight: 700;
}
}
.my-details {
.my-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.my-exp {
font-size: 14px;
opacity: 0.8;
}
}
}
.checkin-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.el-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
&:disabled {
opacity: 0.6;
}
}
.streak-info {
font-size: 12px;
opacity: 0.8;
}
}
}
.leaderboard-list {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
overflow: hidden;
.leaderboard-item {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #EBEEF5;
transition: background-color 0.2s;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #F5F7FA;
}
&.is-me {
background-color: #ECF5FF;
}
.rank-section {
width: 48px;
text-align: center;
.rank-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: #909399;
background: #F5F7FA;
&.gold {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
}
&.silver {
background: linear-gradient(135deg, #C0C0C0 0%, #A0A0A0 100%);
color: #fff;
}
&.bronze {
background: linear-gradient(135deg, #CD7F32 0%, #B87333 100%);
color: #fff;
}
}
}
.user-section {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
.user-info {
min-width: 0;
.user-name {
font-size: 15px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-title {
font-size: 13px;
margin-top: 2px;
}
}
}
.stats-section {
display: flex;
gap: 24px;
.stat-item {
text-align: center;
.stat-value {
display: block;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.stat-label {
font-size: 12px;
color: #909399;
}
}
}
}
}
.load-more {
text-align: center;
padding: 16px 0;
}
@media (max-width: 600px) {
.my-rank-card {
flex-direction: column;
text-align: center;
.my-info {
flex-direction: column;
}
}
.leaderboard-item {
flex-wrap: wrap;
.stats-section {
width: 100%;
justify-content: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #EBEEF5;
}
}
}
</style>