feat: 更新激励中心页面,新增徽章、称号、奖励记录页面

This commit is contained in:
Your Name 2026-01-04 12:04:13 +08:00
parent c1ac3a9fed
commit d82ae4e072
5 changed files with 1117 additions and 58 deletions

View File

@ -144,6 +144,30 @@ const routes: RouteRecordRaw[] = [
title: '激励中心'
}
},
{
path: '/my-badges',
name: 'MyBadges',
component: () => import('@/views/MyBadges.vue'),
meta: {
title: '我的徽章'
}
},
{
path: '/my-titles',
name: 'MyTitles',
component: () => import('@/views/MyTitles.vue'),
meta: {
title: '我的称号'
}
},
{
path: '/reward-records',
name: 'RewardRecords',
component: () => import('@/views/RewardRecords.vue'),
meta: {
title: '奖励记录'
}
},
{
path: '/admin/tasks',
name: 'AdminTaskManagement',

View File

@ -23,6 +23,10 @@
<span class="stat-value">{{ continuousCheckIn }}</span>
<span class="stat-label">连续签到</span>
</div>
<div class="stat">
<span class="stat-value">{{ badgeCount }}</span>
<span class="stat-label">获得徽章</span>
</div>
<div class="stat">
<span class="stat-value">{{ achievementCount }}</span>
<span class="stat-label">已获成就</span>
@ -35,6 +39,15 @@
</div>
</div>
<!-- 当前称号展示 -->
<div class="active-title-card" v-if="activeTitle">
<div class="title-badge" :style="{ backgroundColor: activeTitle.titleColor + '20', borderColor: activeTitle.titleColor }">
<span class="title-name" :style="{ color: activeTitle.titleColor }">{{ activeTitle.titleName }}</span>
</div>
<span class="title-desc">当前佩戴的称号</span>
<a-button type="link" size="small" @click="router.push('/my-titles')">更换称号</a-button>
</div>
<!-- 功能模块入口 -->
<div class="module-grid">
<!-- 每日签到 -->
@ -55,10 +68,40 @@
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
</div>
<!-- 我的徽章 -->
<div class="module-card badge" @click="router.push('/my-badges')">
<div class="module-icon">
<Icon name="star" size="xl" color="#FFD700" />
</div>
<div class="module-info">
<h3 class="module-title">我的徽章</h3>
<p class="module-desc">收集徽章,展示成就</p>
</div>
<div class="module-progress" v-if="badgeCount > 0">
<span>已获 {{ badgeCount }} 个</span>
</div>
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
</div>
<!-- 我的称号 -->
<div class="module-card title" @click="router.push('/my-titles')">
<div class="module-icon">
<Icon name="badge" size="xl" color="#8B5CF6" />
</div>
<div class="module-info">
<h3 class="module-title">我的称号</h3>
<p class="module-desc">解锁称号,彰显身份</p>
</div>
<div class="module-progress" v-if="titleCount > 0">
<span>已有 {{ titleCount }} 个</span>
</div>
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
</div>
<!-- 成就中心 -->
<div class="module-card achievement" @click="router.push('/achievements')">
<div class="module-icon">
<Icon name="trophy" size="xl" color="#FFD700" />
<Icon name="trophy" size="xl" color="#31f08b" />
</div>
<div class="module-info">
<h3 class="module-title">成就中心</h3>
@ -118,7 +161,7 @@
<!-- 排行榜 -->
<div class="module-card leaderboard" @click="router.push('/designer-hall')">
<div class="module-icon">
<Icon name="chartBar" size="xl" color="#8B5CF6" />
<Icon name="chartBar" size="xl" color="#10B981" />
</div>
<div class="module-info">
<h3 class="module-title">排行榜</h3>
@ -131,30 +174,52 @@
</div>
</div>
<!-- 积分记录 -->
<!-- 奖励记录 -->
<div class="section">
<div class="section-header">
<h2 class="section-title">
<Icon name="clock" size="lg" color="#AFAFAF" style="margin-right: 8px;" />
积分记录
奖励记录
</h2>
<a-button type="link" @click="viewAllRecords">查看全部</a-button>
<a-button type="link" @click="router.push('/reward-records')">查看全部</a-button>
</div>
<div class="record-list">
<div class="record-list" v-if="!recordsLoading">
<div v-for="record in recentRecords" :key="record.id" class="record-item">
<div class="record-info">
<Icon :name="getRecordIcon(record.type)" size="md" :color="record.score > 0 ? '#31f08b' : '#EF4444'" />
<Icon :name="getRecordIcon(record.rewardType)" size="md" color="#31f08b" />
<div class="record-detail">
<span class="record-title">{{ record.title }}</span>
<span class="record-time">{{ formatTime(record.time) }}</span>
<span class="record-title">{{ record.ruleName || '系统奖励' }}</span>
<span class="record-time">{{ formatTime(record.createTime) }}</span>
</div>
</div>
<div :class="['record-score', record.score > 0 ? 'positive' : 'negative']">
{{ record.score > 0 ? '+' : '' }}{{ record.score }}
<div class="record-score positive">
{{ parseRewardValue(record.rewardValue) }}
</div>
</div>
<div v-if="recentRecords.length === 0" class="empty-records">
暂无积分记录
暂无奖励记录,快去完成任务获取奖励吧!
</div>
</div>
<div v-else class="loading-records">
<a-spin />
</div>
</div>
<!-- 我的徽章展示 -->
<div class="section" v-if="myBadges.length > 0">
<div class="section-header">
<h2 class="section-title">
<Icon name="star" size="lg" color="#FFD700" style="margin-right: 8px;" />
我的徽章
</h2>
<a-button type="link" @click="router.push('/my-badges')">查看全部</a-button>
</div>
<div class="badges-showcase">
<div v-for="badge in myBadges.slice(0, 6)" :key="badge.id" class="badge-item">
<div class="badge-icon" :style="{ backgroundColor: badge.badgeColor }">
{{ badge.badgeName.charAt(0) }}
</div>
<span class="badge-name">{{ badge.badgeName }}</span>
</div>
</div>
</div>
@ -174,6 +239,15 @@ import { hasCheckedInToday, getCheckInStats } from '@/api/incentive'
import { getMyGoals } from '@/api/goal'
import { getContestList } from '@/api/contest'
import { getTeamPKList } from '@/api/teamPK'
import {
getMyRewardRecords,
getMyBadges,
getMyTitles,
getMyActiveTitle,
type RewardRecord,
type UserBadge,
type UserTitle
} from '@/api/rewardRule'
const router = useRouter()
const authStore = useAuthStore()
@ -189,18 +263,15 @@ const myRank = ref(0)
const unclaimedAchievements = ref(0)
const hasCheckedIn = ref(false)
// 积分记录
const recentRecords = ref<Array<{
id: number
type: string
title: string
score: number
time: string
}>>([
{ id: 1, type: 'checkin', title: '每日签到奖励', score: 10, time: new Date().toISOString() },
{ id: 2, type: 'task', title: '完成任务获得积分', score: 50, time: new Date(Date.now() - 86400000).toISOString() },
{ id: 3, type: 'achievement', title: '解锁成就「初出茅庐」', score: 100, time: new Date(Date.now() - 172800000).toISOString() },
])
// 新增:徽章和称号数据
const badgeCount = ref(0)
const titleCount = ref(0)
const myBadges = ref<UserBadge[]>([])
const activeTitle = ref<UserTitle | null>(null)
// 奖励记录
const recordsLoading = ref(false)
const recentRecords = ref<RewardRecord[]>([])
const loadData = async () => {
try {
@ -224,18 +295,41 @@ const loadData = async () => {
activePKCount.value = pks.records?.length || 0
} catch (error) {
console.error('加载数据失败:', error)
console.error('加载基础数据失败:', error)
}
}
const loadRewardData = async () => {
try {
// 加载奖励记录
recordsLoading.value = true
const recordsResult = await getMyRewardRecords({ page: 1, pageSize: 5 })
recentRecords.value = recordsResult.records || []
// 加载我的徽章
myBadges.value = await getMyBadges()
badgeCount.value = myBadges.value.length
// 加载我的称号
const titles = await getMyTitles()
titleCount.value = titles.length
// 加载当前激活称号
activeTitle.value = await getMyActiveTitle()
} catch (error) {
console.error('加载奖励数据失败:', error)
} finally {
recordsLoading.value = false
}
}
const getRecordIcon = (type: string) => {
const iconMap: Record<string, string> = {
checkin: 'calendar',
task: 'document',
achievement: 'trophy',
contest: 'fire',
pk: 'bolt',
goal: 'flag'
score: 'star',
badge: 'star',
title: 'badge',
exp: 'zap'
}
return iconMap[type] || 'star'
}
@ -244,19 +338,31 @@ const formatTime = (time: string) => {
return dayjs(time).format('MM-DD HH:mm')
}
const viewAllRecords = () => {
// TODO: 跳转到积分记录详情页
const parseRewardValue = (value: string) => {
try {
const obj = JSON.parse(value)
if (obj.score) return `+${obj.score} 积分`
if (obj.badge) return `获得徽章`
if (obj.title) return `获得称号`
return '+奖励'
} catch {
return value
}
}
onMounted(() => {
loadData()
loadRewardData()
})
</script>
<style scoped lang="scss">
@import "@/styles/variables.scss";
.incentive-center {
min-height: 100vh;
padding: 24px;
background: $bg-primary;
}
.container {
@ -271,23 +377,23 @@ onMounted(() => {
.page-title {
font-size: 28px;
font-weight: 600;
color: #ECECEC;
color: $text-primary;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.page-desc {
color: #AFAFAF;
color: $text-secondary;
font-size: 14px;
}
.score-card {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid #3A3A3A;
border: 1px solid $border-primary;
border-radius: 16px;
padding: 32px;
margin-bottom: 32px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
@ -295,7 +401,7 @@ onMounted(() => {
.score-main {
.score-label {
color: #AFAFAF;
color: $text-secondary;
font-size: 14px;
margin-bottom: 8px;
}
@ -310,7 +416,7 @@ onMounted(() => {
.score-stats {
display: flex;
gap: 40px;
gap: 32px;
}
.stat {
@ -320,17 +426,46 @@ onMounted(() => {
display: block;
font-size: 24px;
font-weight: 600;
color: #ECECEC;
color: $text-primary;
}
.stat-label {
display: block;
font-size: 12px;
color: #6B6B6B;
color: $text-tertiary;
margin-top: 4px;
}
}
// 当前称号卡片
.active-title-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 16px;
.title-badge {
padding: 6px 16px;
border-radius: 20px;
border: 2px solid;
}
.title-name {
font-weight: 600;
font-size: 14px;
}
.title-desc {
color: $text-tertiary;
font-size: 13px;
flex: 1;
}
}
.module-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -339,8 +474,8 @@ onMounted(() => {
}
.module-card {
background: #2A2A2A;
border: 1px solid #3A3A3A;
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
display: flex;
@ -351,16 +486,18 @@ onMounted(() => {
position: relative;
&:hover {
border-color: #31f08b;
border-color: $brand-primary;
transform: translateX(4px);
}
&.checkin:hover { border-color: #31f08b; }
&.achievement:hover { border-color: #FFD700; }
&.badge:hover { border-color: #FFD700; }
&.title:hover { border-color: #8B5CF6; }
&.achievement:hover { border-color: #31f08b; }
&.goals:hover { border-color: #3B82F6; }
&.contest:hover { border-color: #EF4444; }
&.team-pk:hover { border-color: #F59E0B; }
&.leaderboard:hover { border-color: #8B5CF6; }
&.leaderboard:hover { border-color: #10B981; }
}
.module-icon {
@ -380,19 +517,19 @@ onMounted(() => {
.module-title {
font-size: 16px;
font-weight: 600;
color: #ECECEC;
color: $text-primary;
margin-bottom: 4px;
}
.module-desc {
font-size: 13px;
color: #6B6B6B;
color: $text-tertiary;
}
.module-status, .module-progress, .module-rank {
margin-right: 8px;
font-size: 13px;
color: #AFAFAF;
color: $text-secondary;
}
.module-badge {
@ -428,14 +565,14 @@ onMounted(() => {
.section-title {
font-size: 18px;
font-weight: 600;
color: #ECECEC;
color: $text-primary;
display: flex;
align-items: center;
}
.record-list {
background: #2A2A2A;
border: 1px solid #3A3A3A;
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
overflow: hidden;
}
@ -445,7 +582,7 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #3A3A3A;
border-bottom: 1px solid $border-primary;
&:last-child {
border-bottom: none;
@ -464,12 +601,12 @@ onMounted(() => {
}
.record-title {
color: #ECECEC;
color: $text-primary;
font-size: 14px;
}
.record-time {
color: #6B6B6B;
color: $text-tertiary;
font-size: 12px;
margin-top: 2px;
}
@ -479,7 +616,7 @@ onMounted(() => {
font-weight: 600;
&.positive {
color: #31f08b;
color: $brand-primary;
}
&.negative {
@ -487,10 +624,45 @@ onMounted(() => {
}
}
.empty-records {
.empty-records, .loading-records {
padding: 32px;
text-align: center;
color: #6B6B6B;
color: $text-tertiary;
}
// 徽章展示区
.badges-showcase {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.badge-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: 600;
}
.badge-name {
font-size: 12px;
color: $text-secondary;
}
}
@media (max-width: 768px) {
@ -503,5 +675,16 @@ onMounted(() => {
gap: 24px;
text-align: center;
}
.score-stats {
flex-wrap: wrap;
justify-content: center;
}
.active-title-card {
flex-wrap: wrap;
justify-content: center;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,270 @@
<template>
<DefaultLayout>
<div class="my-badges-page">
<div class="container">
<!-- 页面标题 -->
<div class="page-header">
<a-button type="text" @click="router.back()" class="back-btn">
<Icon name="chevronLeft" size="md" color="#AFAFAF" />
</a-button>
<div class="header-info">
<h1 class="page-title">
<Icon name="star" size="xl" color="#FFD700" style="margin-right: 12px;" />
我的徽章
</h1>
<p class="page-desc">收集徽章,展示你的成就</p>
</div>
</div>
<!-- 统计信息 -->
<div class="stats-bar">
<div class="stat-item">
<span class="stat-value">{{ myBadges.length }}</span>
<span class="stat-label">已获得</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ allBadges.length }}</span>
<span class="stat-label">全部徽章</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ progressPercent }}%</span>
<span class="stat-label">收集进度</span>
</div>
</div>
<!-- 徽章展示 -->
<div class="badges-section">
<h2 class="section-title">全部徽章</h2>
<div class="badges-grid" v-if="!loading">
<div
v-for="badge in allBadges"
:key="badge.badgeCode"
:class="['badge-card', { 'unlocked': isUnlocked(badge.badgeCode) }]"
>
<div class="badge-icon" :style="{ backgroundColor: isUnlocked(badge.badgeCode) ? badge.badgeColor : '#3A3A3A' }">
{{ badge.badgeName.charAt(0) }}
</div>
<div class="badge-info">
<h3 class="badge-name">{{ badge.badgeName }}</h3>
<p class="badge-desc">{{ badge.description }}</p>
<a-tag v-if="isUnlocked(badge.badgeCode)" color="green">已获得</a-tag>
<a-tag v-else color="default">未解锁</a-tag>
</div>
<div class="badge-time" v-if="getUnlockTime(badge.badgeCode)">
{{ formatTime(getUnlockTime(badge.badgeCode)!) }}
</div>
</div>
</div>
<div v-else class="loading-container">
<a-spin size="large" />
</div>
</div>
</div>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import Icon from '@/components/Icon.vue'
import dayjs from 'dayjs'
import {
getMyBadges,
getAllBadges,
type UserBadge,
type BadgeDefinition
} from '@/api/rewardRule'
const router = useRouter()
const loading = ref(false)
const myBadges = ref<UserBadge[]>([])
const allBadges = ref<BadgeDefinition[]>([])
const progressPercent = computed(() => {
if (allBadges.value.length === 0) return 0
return Math.round((myBadges.value.length / allBadges.value.length) * 100)
})
const isUnlocked = (badgeCode: string) => {
return myBadges.value.some(b => b.badgeCode === badgeCode)
}
const getUnlockTime = (badgeCode: string): string | undefined => {
const badge = myBadges.value.find(b => b.badgeCode === badgeCode)
return badge?.createTime
}
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD')
}
const loadData = async () => {
loading.value = true
try {
const [badges, all] = await Promise.all([
getMyBadges(),
getAllBadges()
])
myBadges.value = badges
allBadges.value = all
} catch (error) {
console.error('加载徽章数据失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
@import "@/styles/variables.scss";
.my-badges-page {
min-height: 100vh;
padding: 24px;
background: $bg-primary;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.back-btn {
margin-top: 4px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: $text-primary;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.page-desc {
color: $text-secondary;
font-size: 14px;
}
.stats-bar {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-around;
margin-bottom: 32px;
}
.stat-item {
text-align: center;
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: #FFD700;
}
.stat-label {
display: block;
font-size: 13px;
color: $text-tertiary;
margin-top: 4px;
}
}
.badges-section {
.section-title {
font-size: 18px;
font-weight: 600;
color: $text-primary;
margin-bottom: 16px;
}
}
.badges-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.badge-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
display: flex;
gap: 16px;
opacity: 0.5;
transition: all 0.3s;
&.unlocked {
opacity: 1;
border-color: #FFD700;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.2);
}
}
.badge-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24px;
font-weight: 600;
flex-shrink: 0;
}
.badge-info {
flex: 1;
.badge-name {
font-size: 16px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
}
.badge-desc {
font-size: 13px;
color: $text-secondary;
margin-bottom: 8px;
}
}
.badge-time {
font-size: 12px;
color: $text-tertiary;
white-space: nowrap;
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 48px;
}
</style>

View File

@ -0,0 +1,318 @@
<template>
<DefaultLayout>
<div class="my-titles-page">
<div class="container">
<!-- 页面标题 -->
<div class="page-header">
<a-button type="text" @click="router.back()" class="back-btn">
<Icon name="chevronLeft" size="md" color="#AFAFAF" />
</a-button>
<div class="header-info">
<h1 class="page-title">
<Icon name="badge" size="xl" color="#8B5CF6" style="margin-right: 12px;" />
我的称号
</h1>
<p class="page-desc">选择一个称号展示在你的名字旁边</p>
</div>
</div>
<!-- 当前称号 -->
<div class="current-title-card">
<div class="card-label">当前佩戴</div>
<div class="current-title" v-if="activeTitle">
<span class="title-name" :style="{ color: activeTitle.titleColor }">
{{ activeTitle.titleName }}
</span>
<a-button type="link" size="small" @click="handleRemoveTitle">取消佩戴</a-button>
</div>
<div class="no-title" v-else>
<span>暂未佩戴称号</span>
</div>
</div>
<!-- 称号列表 -->
<div class="titles-section">
<h2 class="section-title">我的称号</h2>
<div class="titles-grid" v-if="!loading">
<div
v-for="title in myTitles"
:key="title.id"
:class="['title-card', { 'active': activeTitle?.id === title.id }]"
@click="handleSelectTitle(title)"
>
<div class="title-badge" :style="{ backgroundColor: title.titleColor + '20', borderColor: title.titleColor }">
<span class="title-name" :style="{ color: title.titleColor }">{{ title.titleName }}</span>
</div>
<p class="title-desc">{{ title.description }}</p>
<div class="title-meta">
<span class="title-time">获得于 {{ formatTime(title.createTime) }}</span>
<a-tag v-if="activeTitle?.id === title.id" color="purple">使用中</a-tag>
</div>
</div>
<div v-if="myTitles.length === 0" class="empty-titles">
暂无称号,完成任务获取称号吧!
</div>
</div>
<div v-else class="loading-container">
<a-spin size="large" />
</div>
</div>
<!-- 全部称号 -->
<div class="titles-section">
<h2 class="section-title">全部称号</h2>
<div class="titles-grid">
<div
v-for="title in allTitles"
:key="title.titleCode"
:class="['title-card', 'all', { 'unlocked': isUnlocked(title.titleCode) }]"
>
<div class="title-badge" :style="{
backgroundColor: isUnlocked(title.titleCode) ? title.titleColor + '20' : '#2A2A2A',
borderColor: isUnlocked(title.titleCode) ? title.titleColor : '#3A3A3A'
}">
<span class="title-name" :style="{ color: isUnlocked(title.titleCode) ? title.titleColor : '#6B6B6B' }">
{{ title.titleName }}
</span>
</div>
<p class="title-desc">{{ title.description }}</p>
<a-tag v-if="isUnlocked(title.titleCode)" color="green">已解锁</a-tag>
<a-tag v-else color="default">未解锁</a-tag>
</div>
</div>
</div>
</div>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import Icon from '@/components/Icon.vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import {
getMyTitles,
getAllTitles,
getMyActiveTitle,
setActiveTitle,
type UserTitle,
type TitleDefinition
} from '@/api/rewardRule'
const router = useRouter()
const loading = ref(false)
const myTitles = ref<UserTitle[]>([])
const allTitles = ref<TitleDefinition[]>([])
const activeTitle = ref<UserTitle | null>(null)
const isUnlocked = (titleCode: string) => {
return myTitles.value.some(t => t.titleCode === titleCode)
}
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD')
}
const handleSelectTitle = async (title: UserTitle) => {
try {
await setActiveTitle(title.id)
activeTitle.value = title
message.success('称号已更换')
} catch (error) {
message.error('更换失败')
}
}
const handleRemoveTitle = async () => {
try {
await setActiveTitle(undefined)
activeTitle.value = null
message.success('已取消佩戴')
} catch (error) {
message.error('操作失败')
}
}
const loadData = async () => {
loading.value = true
try {
const [titles, all, active] = await Promise.all([
getMyTitles(),
getAllTitles(),
getMyActiveTitle()
])
myTitles.value = titles
allTitles.value = all
activeTitle.value = active
} catch (error) {
console.error('加载称号数据失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
@import "@/styles/variables.scss";
.my-titles-page {
min-height: 100vh;
padding: 24px;
background: $bg-primary;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.back-btn {
margin-top: 4px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: $text-primary;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.page-desc {
color: $text-secondary;
font-size: 14px;
}
.current-title-card {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid $border-primary;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
.card-label {
font-size: 12px;
color: $text-tertiary;
margin-bottom: 12px;
}
.current-title {
display: flex;
align-items: center;
justify-content: space-between;
.title-name {
font-size: 24px;
font-weight: 700;
}
}
.no-title {
color: $text-tertiary;
font-size: 14px;
}
}
.titles-section {
margin-bottom: 32px;
.section-title {
font-size: 18px;
font-weight: 600;
color: $text-primary;
margin-bottom: 16px;
}
}
.titles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.title-card {
background: $bg-card;
border: 2px solid $border-primary;
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #8B5CF6;
}
&.active {
border-color: #8B5CF6;
background: rgba(139, 92, 246, 0.1);
}
&.all {
cursor: default;
&:not(.unlocked) {
opacity: 0.5;
}
}
.title-badge {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
border: 2px solid;
margin-bottom: 12px;
}
.title-name {
font-weight: 600;
font-size: 16px;
}
.title-desc {
font-size: 13px;
color: $text-secondary;
margin-bottom: 12px;
}
.title-meta {
display: flex;
justify-content: space-between;
align-items: center;
.title-time {
font-size: 12px;
color: $text-tertiary;
}
}
}
.empty-titles {
grid-column: 1 / -1;
text-align: center;
padding: 48px;
color: $text-tertiary;
}
.loading-container {
display: flex;
justify-content: center;
padding: 48px;
}
</style>

View File

@ -0,0 +1,264 @@
<template>
<DefaultLayout>
<div class="reward-records-page">
<div class="container">
<!-- 页面标题 -->
<div class="page-header">
<a-button type="text" @click="router.back()" class="back-btn">
<Icon name="chevronLeft" size="md" color="#AFAFAF" />
</a-button>
<div class="header-info">
<h1 class="page-title">
<Icon name="clock" size="xl" color="#31f08b" style="margin-right: 12px;" />
奖励记录
</h1>
<p class="page-desc">查看你获得的所有奖励</p>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">{{ totalScore }}</div>
<div class="stat-label">累计获得积分</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ totalCount }}</div>
<div class="stat-label">获奖次数</div>
</div>
</div>
<!-- 记录列表 -->
<div class="records-section">
<a-table
:columns="columns"
:data-source="records"
:loading="loading"
:pagination="{
current: currentPage,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: false
}"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rewardType'">
<a-tag :color="getTypeColor(record.rewardType)">
{{ getTypeName(record.rewardType) }}
</a-tag>
</template>
<template v-if="column.key === 'rewardValue'">
<span class="reward-value">{{ parseRewardValue(record.rewardValue) }}</span>
</template>
<template v-if="column.key === 'createTime'">
{{ formatTime(record.createTime) }}
</template>
</template>
</a-table>
</div>
</div>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import Icon from '@/components/Icon.vue'
import dayjs from 'dayjs'
import {
getMyRewardRecords,
type RewardRecord
} from '@/api/rewardRule'
const router = useRouter()
const loading = ref(false)
const records = ref<RewardRecord[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const columns = [
{ title: '奖励规则', dataIndex: 'ruleName', key: 'ruleName' },
{ title: '奖励类型', dataIndex: 'rewardType', key: 'rewardType', width: 120 },
{ title: '奖励内容', dataIndex: 'rewardValue', key: 'rewardValue', width: 150 },
{ title: '获得时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
]
const totalScore = computed(() => {
return records.value.reduce((sum, r) => {
try {
const obj = JSON.parse(r.rewardValue)
return sum + (obj.score || 0)
} catch {
return sum
}
}, 0)
})
const totalCount = computed(() => total.value)
const getTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
score: 'green',
badge: 'gold',
title: 'purple',
exp: 'blue'
}
return colorMap[type] || 'default'
}
const getTypeName = (type: string) => {
const nameMap: Record<string, string> = {
score: '积分',
badge: '徽章',
title: '称号',
exp: '经验值'
}
return nameMap[type] || type
}
const parseRewardValue = (value: string) => {
try {
const obj = JSON.parse(value)
if (obj.score) return `+${obj.score} 积分`
if (obj.badge) return `徽章: ${obj.badge}`
if (obj.title) return `称号: ${obj.title}`
return value
} catch {
return value
}
}
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm')
}
const handlePageChange = (page: number) => {
currentPage.value = page
loadData()
}
const loadData = async () => {
loading.value = true
try {
const result = await getMyRewardRecords({
page: currentPage.value,
pageSize: pageSize.value
})
records.value = result.records || []
total.value = result.total || 0
} catch (error) {
console.error('加载奖励记录失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
@import "@/styles/variables.scss";
.reward-records-page {
min-height: 100vh;
padding: 24px;
background: $bg-primary;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.back-btn {
margin-top: 4px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: $text-primary;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.page-desc {
color: $text-secondary;
font-size: 14px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 24px;
text-align: center;
.stat-value {
font-size: 36px;
font-weight: 700;
color: $brand-primary;
}
.stat-label {
font-size: 14px;
color: $text-secondary;
margin-top: 8px;
}
}
.records-section {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
}
.reward-value {
color: $brand-primary;
font-weight: 600;
}
:deep(.ant-table) {
background: transparent;
.ant-table-thead > tr > th {
background: $bg-secondary;
border-bottom: 1px solid $border-primary;
color: $text-secondary;
}
.ant-table-tbody > tr > td {
border-bottom: 1px solid $border-primary;
color: $text-primary;
}
.ant-table-tbody > tr:hover > td {
background: $bg-secondary;
}
}
</style>