使用 Math.round(score * 100) / 100 避免 JavaScript 浮点数计算误差 例如 0.1 × 46 现在正确显示为 4.60 而不是 4.600000000000005
698 lines
18 KiB
Vue
698 lines
18 KiB
Vue
<template>
|
||
<DefaultLayout>
|
||
<div class="incentive-center">
|
||
<div class="container">
|
||
<!-- 页面标题 -->
|
||
<div class="page-header">
|
||
<h1 class="page-title">
|
||
<Icon name="sparkles" size="xl" color="#FFD700" style="margin-right: 12px;" />
|
||
激励中心
|
||
</h1>
|
||
<p class="page-desc">参与活动、达成目标、赢取奖励!</p>
|
||
</div>
|
||
|
||
<!-- 我的积分卡片 -->
|
||
<div class="score-card">
|
||
<div class="score-main">
|
||
<div class="score-label">我的积分</div>
|
||
<div class="score-value">{{ userScore }}</div>
|
||
</div>
|
||
<div class="score-actions">
|
||
<div class="score-stats">
|
||
<div class="stat">
|
||
<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>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-value">{{ activeGoalCount }}</span>
|
||
<span class="stat-label">进行中目标</span>
|
||
</div>
|
||
</div>
|
||
</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">
|
||
<!-- 每日签到 -->
|
||
<div class="module-card checkin" @click="router.push('/checkin')">
|
||
<div class="module-icon">
|
||
<Icon name="calendar" size="xl" color="#31f08b" />
|
||
</div>
|
||
<div class="module-info">
|
||
<h3 class="module-title">每日签到</h3>
|
||
<p class="module-desc">坚持签到,累计积分</p>
|
||
</div>
|
||
<div class="module-status" v-if="hasCheckedIn">
|
||
<a-tag color="green">今日已签</a-tag>
|
||
</div>
|
||
<div class="module-status" v-else>
|
||
<a-tag color="orange">待签到</a-tag>
|
||
</div>
|
||
<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="#31f08b" />
|
||
</div>
|
||
<div class="module-info">
|
||
<h3 class="module-title">成就中心</h3>
|
||
<p class="module-desc">解锁成就,展示荣誉</p>
|
||
</div>
|
||
<div class="module-badge" v-if="unclaimedAchievements > 0">
|
||
<span class="badge-count">{{ unclaimedAchievements }}</span>
|
||
</div>
|
||
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
|
||
</div>
|
||
|
||
<!-- 我的目标 -->
|
||
<div class="module-card goals" @click="router.push('/goals')">
|
||
<div class="module-icon">
|
||
<Icon name="flag" size="xl" color="#3B82F6" />
|
||
</div>
|
||
<div class="module-info">
|
||
<h3 class="module-title">我的目标</h3>
|
||
<p class="module-desc">设定目标,持续成长</p>
|
||
</div>
|
||
<div class="module-progress" v-if="activeGoalCount > 0">
|
||
<span>{{ activeGoalCount }} 个进行中</span>
|
||
</div>
|
||
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
|
||
</div>
|
||
|
||
<!-- 竞赛活动 -->
|
||
<div class="module-card contest" @click="router.push('/contests')">
|
||
<div class="module-icon">
|
||
<Icon name="fire" size="xl" color="#EF4444" />
|
||
</div>
|
||
<div class="module-info">
|
||
<h3 class="module-title">竞赛活动</h3>
|
||
<p class="module-desc">参与竞赛,争夺荣耀</p>
|
||
</div>
|
||
<div class="module-status" v-if="activeContestCount > 0">
|
||
<a-tag color="red">{{ activeContestCount }} 场进行中</a-tag>
|
||
</div>
|
||
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
|
||
</div>
|
||
|
||
<!-- 团队PK -->
|
||
<div class="module-card team-pk" @click="router.push('/team-pk')">
|
||
<div class="module-icon">
|
||
<Icon name="bolt" size="xl" color="#F59E0B" />
|
||
</div>
|
||
<div class="module-info">
|
||
<h3 class="module-title">团队 PK</h3>
|
||
<p class="module-desc">部门对战,为团队而战</p>
|
||
</div>
|
||
<div class="module-status" v-if="activePKCount > 0">
|
||
<a-tag color="orange">{{ activePKCount }} 场对战中</a-tag>
|
||
</div>
|
||
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
|
||
</div>
|
||
|
||
<!-- 排行榜 -->
|
||
<div class="module-card leaderboard" @click="router.push('/designer-hall')">
|
||
<div class="module-icon">
|
||
<Icon name="chartBar" size="xl" color="#10B981" />
|
||
</div>
|
||
<div class="module-info">
|
||
<h3 class="module-title">排行榜</h3>
|
||
<p class="module-desc">查看榜单,了解排名</p>
|
||
</div>
|
||
<div class="module-rank" v-if="myRank > 0">
|
||
<span>当前 #{{ myRank }}</span>
|
||
</div>
|
||
<Icon name="chevronRight" size="md" color="#6B6B6B" class="module-arrow" />
|
||
</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="router.push('/reward-records')">查看全部</a-button>
|
||
</div>
|
||
<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.rewardType)" size="md" color="#31f08b" />
|
||
<div class="record-detail">
|
||
<span class="record-title">{{ record.ruleName || '系统奖励' }}</span>
|
||
<span class="record-time">{{ formatTime(record.createTime) }}</span>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
</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 { useAuthStore } from '@/stores/auth'
|
||
import dayjs from 'dayjs'
|
||
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()
|
||
|
||
// 格式化积分(保留两位小数,修复浮点数精度问题)
|
||
const formatScore = (score: number | null | undefined): string => {
|
||
if (score === null || score === undefined) return '0.00'
|
||
// 先乘以100,四舍五入取整,再除以100,避免浮点数精度问题
|
||
return (Math.round(Number(score) * 100) / 100).toFixed(2)
|
||
}
|
||
|
||
// 用户数据
|
||
const userScore = computed(() => formatScore(authStore.userInfo?.score))
|
||
const continuousCheckIn = ref(0)
|
||
const achievementCount = ref(0)
|
||
const activeGoalCount = ref(0)
|
||
const activeContestCount = ref(0)
|
||
const activePKCount = ref(0)
|
||
const myRank = ref(0)
|
||
const unclaimedAchievements = ref(0)
|
||
const hasCheckedIn = ref(false)
|
||
|
||
// 新增:徽章和称号数据
|
||
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 {
|
||
// 加载签到状态
|
||
hasCheckedIn.value = await hasCheckedInToday()
|
||
|
||
// 加载签到统计
|
||
const stats = await getCheckInStats()
|
||
continuousCheckIn.value = stats.continuousDays || 0
|
||
|
||
// 加载活跃目标数
|
||
const goals = await getMyGoals('active')
|
||
activeGoalCount.value = goals.length
|
||
|
||
// 加载进行中的竞赛数
|
||
const contests = await getContestList({ status: 'active' })
|
||
activeContestCount.value = contests.records?.length || 0
|
||
|
||
// 加载进行中的PK数
|
||
const pks = await getTeamPKList({ status: 'active' })
|
||
activePKCount.value = pks.records?.length || 0
|
||
|
||
} catch (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> = {
|
||
score: 'star',
|
||
badge: 'star',
|
||
title: 'badge',
|
||
exp: 'zap'
|
||
}
|
||
return iconMap[type] || 'star'
|
||
}
|
||
|
||
const formatTime = (time: string) => {
|
||
return dayjs(time).format('MM-DD HH:mm')
|
||
}
|
||
|
||
const parseRewardValue = (value: string) => {
|
||
try {
|
||
const obj = JSON.parse(value)
|
||
if (obj.score) return `+${formatScore(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 {
|
||
max-width: 1000px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-header {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.score-card {
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||
border: 1px solid $border-primary;
|
||
border-radius: 16px;
|
||
padding: 32px;
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.score-main {
|
||
.score-label {
|
||
color: $text-secondary;
|
||
font-size: 14px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.score-value {
|
||
font-size: 48px;
|
||
font-weight: 700;
|
||
color: #FFD700;
|
||
text-shadow: 0 0 20px rgba(255, 215, 0, 0.3);
|
||
}
|
||
}
|
||
|
||
.score-stats {
|
||
display: flex;
|
||
gap: 32px;
|
||
}
|
||
|
||
.stat {
|
||
text-align: center;
|
||
|
||
.stat-value {
|
||
display: block;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.stat-label {
|
||
display: block;
|
||
font-size: 12px;
|
||
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);
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.module-card {
|
||
background: $bg-card;
|
||
border: 1px solid $border-primary;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
position: relative;
|
||
|
||
&:hover {
|
||
border-color: $brand-primary;
|
||
transform: translateX(4px);
|
||
}
|
||
|
||
&.checkin:hover { border-color: #31f08b; }
|
||
&.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: #10B981; }
|
||
}
|
||
|
||
.module-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.module-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.module-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: $text-primary;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.module-desc {
|
||
font-size: 13px;
|
||
color: $text-tertiary;
|
||
}
|
||
|
||
.module-status, .module-progress, .module-rank {
|
||
margin-right: 8px;
|
||
font-size: 13px;
|
||
color: $text-secondary;
|
||
}
|
||
|
||
.module-badge {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 40px;
|
||
|
||
.badge-count {
|
||
background: #EF4444;
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
}
|
||
}
|
||
|
||
.module-arrow {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: $text-primary;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.record-list {
|
||
background: $bg-card;
|
||
border: 1px solid $border-primary;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.record-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid $border-primary;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
}
|
||
|
||
.record-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.record-detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.record-title {
|
||
color: $text-primary;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.record-time {
|
||
color: $text-tertiary;
|
||
font-size: 12px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.record-score {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
|
||
&.positive {
|
||
color: $brand-primary;
|
||
}
|
||
|
||
&.negative {
|
||
color: #EF4444;
|
||
}
|
||
}
|
||
|
||
.empty-records, .loading-records {
|
||
padding: 32px;
|
||
text-align: center;
|
||
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) {
|
||
.module-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.score-card {
|
||
flex-direction: column;
|
||
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>
|