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

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

View File

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

View File

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

View File

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

View File

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