- 后端: 新增 user_levels, exp_history, badge_definitions, user_badges, level_configs 表 - 后端: 新增 LevelService 和 BadgeService 服务 - 后端: 新增等级/奖章/签到/排行榜 API 端点 - 后端: 考试/练习/陪练完成时触发经验值和奖章检查 - 前端: 新增 LevelBadge, ExpProgress, BadgeCard, LevelUpDialog 组件 - 前端: 新增排行榜页面 - 前端: 成长路径页面集成真实等级数据 - 数据库: 包含迁移脚本和初始数据
This commit is contained in:
@@ -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"]
|
||||
|
||||
277
backend/app/api/v1/endpoints/level.py
Normal file
277
backend/app/api/v1/endpoints/level.py
Normal 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)
|
||||
}
|
||||
)
|
||||
@@ -135,7 +135,43 @@ async def submit_exam(
|
||||
)
|
||||
)
|
||||
|
||||
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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -262,7 +262,39 @@ async def end_training(
|
||||
|
||||
logger.info(f"用户 {current_user['id']} 结束陪练会话: {session_id}")
|
||||
|
||||
return ResponseModel(data=response, message="结束陪练成功")
|
||||
# 陪练完成时触发经验值和奖章检查
|
||||
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=result_data, message="结束陪练成功")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -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
153
backend/app/models/level.py
Normal 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" # 时长
|
||||
467
backend/app/services/badge_service.py
Normal file
467
backend/app/services/badge_service.py
Normal 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()
|
||||
588
backend/app/services/level_service.py
Normal file
588
backend/app/services/level_service.py
Normal 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
|
||||
}
|
||||
@@ -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; -- 应该等于用户数
|
||||
```
|
||||
|
||||
192
backend/migrations/add_level_badge_system.sql
Normal file
192
backend/migrations/add_level_badge_system.sql
Normal 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
182
frontend/src/api/level.ts
Normal 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
|
||||
}
|
||||
174
frontend/src/components/BadgeCard.vue
Normal file
174
frontend/src/components/BadgeCard.vue
Normal 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>
|
||||
100
frontend/src/components/ExpProgress.vue
Normal file
100
frontend/src/components/ExpProgress.vue
Normal 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>
|
||||
85
frontend/src/components/LevelBadge.vue
Normal file
85
frontend/src/components/LevelBadge.vue
Normal 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>
|
||||
297
frontend/src/components/LevelUpDialog.vue
Normal file
297
frontend/src/components/LevelUpDialog.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
491
frontend/src/views/trainee/leaderboard.vue
Normal file
491
frontend/src/views/trainee/leaderboard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user