Files
012-kaopeilian/frontend/src/views/trainee/leaderboard.vue
yuliang_guo 64f5d567fa
Some checks failed
continuous-integration/drone/push Build is failing
feat: 实现 KPL 系统功能改进计划
1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
2026-01-30 14:22:35 +08:00

622 lines
14 KiB
Vue

<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;
}
}
}
// 手机端深度优化
@media (max-width: 480px) {
.leaderboard-page {
padding: 12px;
.page-header {
margin-bottom: 16px;
h2 {
font-size: 20px;
text-align: center;
}
}
.my-rank-card {
padding: 16px;
border-radius: 12px;
gap: 16px;
.rank-badge {
.rank-number {
font-size: 28px;
}
.rank-label {
font-size: 11px;
}
}
.my-info {
gap: 10px;
.level-badge {
width: 44px;
height: 44px;
.level-number {
font-size: 18px;
}
}
.my-details {
.my-title {
font-size: 15px;
}
.my-exp {
font-size: 12px;
}
}
}
.checkin-section {
width: 100%;
.el-button {
width: 100%;
min-height: 44px;
font-size: 15px;
}
.streak-info {
font-size: 12px;
margin-top: 8px;
}
}
}
.leaderboard-list {
gap: 10px;
margin-bottom: 16px;
.leaderboard-item {
padding: 12px;
border-radius: 10px;
gap: 10px;
.rank-section {
width: 40px;
.rank-icon {
width: 28px;
height: 28px;
font-size: 12px;
}
}
.user-section {
gap: 10px;
.el-avatar {
width: 36px !important;
height: 36px !important;
}
.user-info {
.user-name {
font-size: 14px;
}
.user-title {
font-size: 12px;
}
}
}
.stats-section {
gap: 16px;
padding-top: 10px;
margin-top: 10px;
.stat-item {
.stat-value {
font-size: 14px;
}
.stat-label {
font-size: 11px;
}
}
}
}
}
.load-more {
padding: 12px 0 20px;
}
}
}
</style>