Some checks failed
continuous-integration/drone/push Build is failing
1. 奖章条件优化 - 修复统计查询 SQL 语法 - 添加按类别检查奖章方法 2. 移动端适配 - 登录页、课程中心、课程详情 - 考试页面、成长路径、排行榜 3. 证书系统 - 数据库模型和迁移脚本 - 证书颁发/列表/下载/验证 API - 前端证书列表页面 4. 数据大屏 - 企业级/团队级数据 API - ECharts 可视化大屏页面
622 lines
14 KiB
Vue
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>
|