qiangdanxitong/frontend/src/views/IncentiveCenter.vue
Your Name eeeba7b685 fix: 修复积分显示的浮点数精度问题
使用 Math.round(score * 100) / 100 避免 JavaScript 浮点数计算误差
例如 0.1 × 46 现在正确显示为 4.60 而不是 4.600000000000005
2026-01-15 15:47:59 +08:00

698 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>