qiangdanxitong/frontend/src/views/Achievements.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

541 lines
13 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="achievements-page">
<div class="container">
<div class="header-stats">
<div class="stat-box">
<div class="stat-value">{{ userAchievementsCount }}</div>
<div class="stat-label">已获得</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ totalAchievementsCount }}</div>
<div class="stat-label">总成就</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ completionRate }}%</div>
<div class="stat-label">完成度</div>
</div>
</div>
<!-- 筛选器 -->
<div class="filter-section">
<a-radio-group v-model:value="filterType" button-style="solid" @change="handleFilterChange">
<a-radio-button value="all">全部成就</a-radio-button>
<a-radio-button value="unlocked">已解锁</a-radio-button>
<a-radio-button value="locked">未解锁</a-radio-button>
</a-radio-group>
<a-select v-model:value="filterCategory" style="width: 150px" @change="handleFilterChange">
<a-select-option value="all">全部类型</a-select-option>
<a-select-option value="task">任务类</a-select-option>
<a-select-option value="score">积分类</a-select-option>
<a-select-option value="quality">质量类</a-select-option>
<a-select-option value="speed">速度类</a-select-option>
</a-select>
</div>
<!-- 成就列表 -->
<a-spin :spinning="loading">
<div v-if="filteredAchievements.length > 0" class="achievements-grid">
<div
v-for="achievement in filteredAchievements"
:key="achievement.id"
class="achievement-card"
:class="{
'unlocked': isUnlocked(achievement.id),
[`level-${achievement.level}`]: true
}"
>
<!-- 徽章图标 -->
<div class="achievement-badge">
<div class="badge-icon">
<Icon name="trophy" size="lg" v-if="achievement.level === 'diamond'" />
<Icon name="crown" size="lg" v-else-if="achievement.level === 'gold'" />
<Icon name="star" size="lg" v-else-if="achievement.level === 'silver'" />
<Icon name="trophy" size="lg" v-else />
</div>
<div v-if="isUnlocked(achievement.id)" class="badge-check">
<Icon name="checkCircle" size="sm" />
</div>
<div v-if="!isUnlocked(achievement.id)" class="badge-lock">
<Icon name="locked" size="sm" />
</div>
</div>
<!-- 成就信息 -->
<div class="achievement-info">
<div class="achievement-name">{{ achievement.achievementName }}</div>
<div class="achievement-desc">{{ achievement.description }}</div>
<div class="achievement-meta">
<a-tag :color="getLevelColor(achievement.level)" size="small">
{{ getLevelText(achievement.level) }}
</a-tag>
<a-tag color="blue" size="small">
{{ getTypeText(achievement.achievementType) }}
</a-tag>
</div>
<!-- 奖励信息 -->
<div class="achievement-reward">
<Icon name="trophy" size="sm" />
奖励 {{ formatScore(achievement.rewardScore) }} 积分
</div>
<!-- 已解锁信息 -->
<div v-if="isUnlocked(achievement.id)" class="achievement-unlocked">
<div class="unlock-info">
<Icon name="checkCircle" size="sm" />
已于 {{ getUnlockTime(achievement.id) }} 解锁
</div>
<a-button
v-if="!isRewardClaimed(achievement.id)"
type="primary"
size="small"
@click="handleClaimReward(achievement.id)"
>
领取奖励
</a-button>
<a-tag v-else color="success" size="small">
已领取
</a-tag>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<a-empty description="暂无成就数据" />
</div>
</a-spin>
</div>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import { message } from 'ant-design-vue'
import Icon from '@/components/Icon.vue'
import {
getAllAchievements,
getUserAchievements,
claimAchievementReward,
type Achievement,
type UserAchievement
} from '@/api/incentive'
const loading = ref(false)
const achievements = ref<Achievement[]>([])
const userAchievements = ref<UserAchievement[]>([])
const filterType = ref('all')
const filterCategory = ref('all')
// 模拟当前用户ID
const currentUserId = ref(1)
// 计算属性
const userAchievementsCount = computed(() => userAchievements.value.length)
const totalAchievementsCount = computed(() => achievements.value.length)
const completionRate = computed(() => {
if (totalAchievementsCount.value === 0) return 0
return Math.round((userAchievementsCount.value / totalAchievementsCount.value) * 100)
})
// 过滤后的成就列表
const filteredAchievements = computed(() => {
let result = achievements.value
// 按解锁状态筛选
if (filterType.value === 'unlocked') {
result = result.filter(a => isUnlocked(a.id))
} else if (filterType.value === 'locked') {
result = result.filter(a => !isUnlocked(a.id))
}
// 按类型筛选
if (filterCategory.value !== 'all') {
result = result.filter(a => a.achievementType === filterCategory.value)
}
return result
})
// 格式化积分(保留两位小数,修复浮点数精度问题)
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 loadAchievements = async () => {
loading.value = true
try {
achievements.value = await getAllAchievements()
} catch (error) {
message.error('加载成就列表失败')
console.error(error)
} finally {
loading.value = false
}
}
// 加载用户成就
const loadUserAchievements = async () => {
try {
userAchievements.value = await getUserAchievements(currentUserId.value)
} catch (error) {
console.error('加载用户成就失败', error)
}
}
// 判断成就是否已解锁
const isUnlocked = (achievementId: number) => {
return userAchievements.value.some(ua => ua.achievementId === achievementId)
}
// 判断奖励是否已领取
const isRewardClaimed = (achievementId: number) => {
const userAch = userAchievements.value.find(ua => ua.achievementId === achievementId)
return userAch?.rewardClaimed === 1
}
// 获取解锁时间
const getUnlockTime = (achievementId: number) => {
const userAch = userAchievements.value.find(ua => ua.achievementId === achievementId)
if (!userAch) return ''
const date = new Date(userAch.achievedTime)
return date.toLocaleDateString('zh-CN')
}
// 领取奖励
const handleClaimReward = async (achievementId: number) => {
try {
await claimAchievementReward(achievementId, currentUserId.value)
message.success('奖励领取成功!')
await loadUserAchievements()
} catch (error) {
message.error('领取失败')
console.error(error)
}
}
// 筛选改变
const handleFilterChange = () => {
// 触发重新计算
}
// 获取等级颜色
const getLevelColor = (level: string) => {
switch (level) {
case 'diamond':
return 'purple'
case 'gold':
return 'gold'
case 'silver':
return 'default'
case 'bronze':
return 'orange'
default:
return 'default'
}
}
// 获取等级文本
const getLevelText = (level: string) => {
switch (level) {
case 'diamond':
return '钻石'
case 'gold':
return '金牌'
case 'silver':
return '银牌'
case 'bronze':
return '铜牌'
default:
return level
}
}
// 获取类型文本
const getTypeText = (type: string) => {
switch (type) {
case 'task':
return '任务类'
case 'score':
return '积分类'
case 'quality':
return '质量类'
case 'speed':
return '速度类'
default:
return type
}
}
onMounted(() => {
loadAchievements()
loadUserAchievements()
})
</script>
<style lang="scss" scoped>
.achievements-page {
min-height: 100vh;
padding: $space-xl 0;
background: $bg-primary;
}
.container {
max-width: $container-max-width;
margin: 0 auto;
padding: 0 $space-xl;
@media (max-width: $breakpoint-tablet) {
padding: 0 $space-lg;
}
@media (max-width: $breakpoint-mobile) {
padding: 0 $space-md;
}
}
.header-stats {
display: flex;
gap: $space-lg;
margin-bottom: $space-2xl;
.stat-box {
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: $space-lg;
min-width: 100px;
text-align: center;
.stat-value {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $primary-color;
margin-bottom: $space-xs;
}
.stat-label {
font-size: $font-size-sm;
color: $text-secondary;
}
}
}
.filter-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $space-xl;
}
// 图表卡片 - Cursor 风格
.chart-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-xl;
transition: all $transition-base $ease-out;
&:hover {
border-color: $border-color;
background: $bg-card-hover;
}
}
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: $space-lg;
}
.achievement-card {
background: $bg-card;
border: 2px solid $border-primary;
border-radius: $radius-lg;
padding: $space-xl;
display: flex;
flex-direction: column;
align-items: center;
gap: $space-lg;
transition: all $transition-base $ease-out;
position: relative;
overflow: hidden;
&:hover {
border-color: $border-color;
background: $bg-card-hover;
box-shadow: $shadow-base;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #ddd, #ddd);
}
&.level-bronze::before {
background: rgba(#31f08b, 0.3);
}
&.level-silver::before {
background: rgba(#31f08b, 0.4);
}
&.level-gold::before {
background: rgba(#31f08b, 0.5);
}
&.level-diamond::before {
background: rgba(#31f08b, 0.6);
}
&.unlocked {
border-color: $brand-primary;
background: rgba(#31f08b, 0.05);
.achievement-badge {
filter: grayscale(0);
}
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
}
.achievement-badge {
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
filter: grayscale(1);
transition: all 0.3s ease-out;
.level-bronze & {
background: rgba(#31f08b, 0.2);
}
.level-silver & {
background: rgba(#31f08b, 0.3);
}
.level-gold & {
background: rgba(#31f08b, 0.4);
}
.level-diamond & {
background: rgba(#31f08b, 0.5);
}
.badge-icon {
font-size: 60px;
color: white;
}
.badge-check,
.badge-lock {
position: absolute;
bottom: -5px;
right: -5px;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: 3px solid $bg-secondary;
}
.badge-check {
background: #31f08b;
color: white;
}
.badge-lock {
background: $bg-tertiary;
color: $text-tertiary;
border: 1px solid $border-primary;
}
}
.achievement-info {
width: 100%;
text-align: center;
}
.achievement-name {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-primary;
margin-bottom: $space-sm;
}
.achievement-desc {
font-size: $font-size-base;
color: $text-secondary;
margin-bottom: $space-md;
line-height: 1.6;
}
.achievement-meta {
display: flex;
justify-content: center;
gap: $space-sm;
margin-bottom: $space-md;
}
.achievement-reward {
display: inline-flex;
align-items: center;
gap: $space-xs;
padding: $space-sm $space-lg;
background: rgba(#31f08b, 0.1);
border: 1px solid rgba(#31f08b, 0.3);
border-radius: $radius-md;
color: #31f08b;
font-weight: $font-weight-bold;
margin-bottom: $space-md;
}
.achievement-unlocked {
width: 100%;
padding-top: $space-md;
border-top: 1px solid $border-color;
display: flex;
flex-direction: column;
gap: $space-sm;
align-items: center;
.unlock-info {
font-size: $font-size-sm;
color: $text-tertiary;
display: flex;
align-items: center;
gap: $space-xs;
}
}
.empty-state {
padding: $space-3xl 0;
text-align: center;
}
</style>