- 后端: 新增 user_levels, exp_history, badge_definitions, user_badges, level_configs 表 - 后端: 新增 LevelService 和 BadgeService 服务 - 后端: 新增等级/奖章/签到/排行榜 API 端点 - 后端: 考试/练习/陪练完成时触发经验值和奖章检查 - 前端: 新增 LevelBadge, ExpProgress, BadgeCard, LevelUpDialog 组件 - 前端: 新增排行榜页面 - 前端: 成长路径页面集成真实等级数据 - 数据库: 包含迁移脚本和初始数据
This commit is contained in:
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