Files

1265 lines
33 KiB
Vue

<template>
<view class="growth-page" :style="{ background: themeConfig.bgColor }">
<view class="bg-decoration">
<view class="deco-blob deco-1" :style="{ background: `radial-gradient(circle, ${themeConfig.primaryColor}28 0%, transparent 70%)` }"></view>
<view class="deco-blob deco-2" :style="{ background: `radial-gradient(circle, ${themeConfig.secondaryColor}22 0%, transparent 70%)` }"></view>
<view class="deco-blob deco-3" :style="{ background: `radial-gradient(circle, ${themeConfig.accentColor}18 0%, transparent 70%)` }"></view>
</view>
<scroll-view class="scroll-container" scroll-y :show-scrollbar="false">
<view class="header-card" :style="{ background: themeConfig.gradientVibrant, boxShadow: `0 16rpx 48rpx ${themeConfig.shadowColor}` }">
<view class="card-bg">
<view class="bg-orb"></view>
<view class="bg-grid"></view>
<view class="bg-float-shape shape-1" :style="{ background: `linear-gradient(135deg, rgba(255,255,255,0.25) 0%, rgba(255,255,255,0.08) 100%)` }"></view>
<view class="bg-float-shape shape-2" :style="{ background: `linear-gradient(135deg, ${themeConfig.secondaryColor}40 0%, transparent 100%)` }"></view>
</view>
<view class="card-content">
<view class="user-card">
<view class="avatar-ring" :style="{ background: 'rgba(255, 255, 255, 0.25)' }">
<FaIcon v-if="growthInfo.levelIcon && growthInfo.levelIcon.startsWith && !growthInfo.levelIcon.startsWith('\\u')" :icon="growthInfo.levelIcon" :size="40" />
<FaIcon v-else icon="trophy" :size="40" color="#fff" />
</view>
<view class="user-info">
<text class="user-name">{{ growthInfo.levelTitle || '记录小白' }}</text>
<view class="level-badge" :style="{ background: 'rgba(255, 255, 255, 0.2)' }">
<text>Lv.{{ growthInfo.currentLevel }}</text>
</view>
</view>
</view>
<view class="exp-section">
<view class="exp-header">
<text class="exp-label">当前经验</text>
<view class="exp-value">
<text class="exp-current">{{ animatedExp }}</text>
<text class="exp-divider">/</text>
<text class="exp-max">{{ growthInfo.nextLevelExp }} EXP</text>
</view>
</view>
<view class="progress-wrapper">
<view class="progress-bar" :style="{ background: 'rgba(255, 255, 255, 0.25)' }">
<view class="progress-fill" :style="{ width: animatedProgress + '%', background: 'linear-gradient(90deg, #fff 0%, rgba(255,255,255,0.8) 100%)' }">
<view class="progress-shine"></view>
</view>
</view>
<view class="progress-text">{{ animatedProgress }}%</view>
</view>
</view>
</view>
</view>
<view class="content-area">
<view class="stats-row">
<view class="stat-item" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 6rpx 24rpx ${themeConfig.shadowLight}` }">
<view class="stat-icon-wrap" :style="{ background: `${themeConfig.primaryColor}12` }">
<FaIcon icon="calendar-check" :size="22" :color="themeConfig.primaryColor" />
</view>
<text class="stat-num" :style="{ color: themeConfig.textPrimary }">{{ growthInfo.totalDays }}</text>
<text class="stat-label" :style="{ color: themeConfig.textSecondary }">累计天数</text>
</view>
<view class="stat-item" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 6rpx 24rpx ${themeConfig.shadowLight}` }">
<view class="stat-icon-wrap" :style="{ background: `${themeConfig.warningColor}12` }">
<FaIcon icon="fire" :size="22" :color="themeConfig.warningColor" />
</view>
<text class="stat-num" :style="{ color: themeConfig.textPrimary }">{{ growthInfo.streakDays }}</text>
<text class="stat-label" :style="{ color: themeConfig.textSecondary }">连续打卡</text>
</view>
<view class="stat-item" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 6rpx 24rpx ${themeConfig.shadowLight}` }">
<view class="stat-icon-wrap" :style="{ background: `${themeConfig.successColor}12` }">
<FaIcon icon="trophy" :size="22" :color="themeConfig.successColor" />
</view>
<text class="stat-num" :style="{ color: themeConfig.textPrimary }">{{ growthInfo.maxStreakDays }}</text>
<text class="stat-label" :style="{ color: themeConfig.textSecondary }">最高连续</text>
</view>
</view>
<view class="rules-entry" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 8rpx 32rpx ${themeConfig.shadowLight}` }" @click="goToRules">
<view class="entry-icon" :style="{ background: `linear-gradient(135deg, ${themeConfig.primaryColor}15 0%, ${themeConfig.secondaryColor}10 100%)` }">
<FaIcon icon="book-open" :size="24" :color="themeConfig.primaryColor" />
</view>
<view class="entry-text">
<text class="entry-title" :style="{ color: themeConfig.textPrimary }">成长规则</text>
<text class="entry-desc" :style="{ color: themeConfig.textSecondary }">了解升级攻略和特权说明</text>
</view>
<view class="entry-arrow" :style="{ background: `${themeConfig.primaryColor}12` }">
<FaIcon icon="chevron-right" :size="14" :color="themeConfig.primaryColor" />
</view>
</view>
<view class="module-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 8rpx 32rpx ${themeConfig.shadowLight}` }">
<view class="module-header">
<view class="module-icon-wrap" :style="{ background: `${themeConfig.primaryColor}15` }">
<FaIcon icon="chart-column" :size="22" :color="themeConfig.primaryColor" />
</view>
<view class="module-title-wrap">
<text class="module-title" :style="{ color: themeConfig.textPrimary }">今日进度</text>
<text class="module-subtitle" :style="{ color: themeConfig.textSecondary }">{{ todayDate }}</text>
</view>
</view>
<view class="today-exp">
<view class="exp-display">
<text class="exp-number" :style="{ background: themeConfig.gradientSoft, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }">{{ growthInfo.todayExp }}</text>
<text class="exp-slash" :style="{ color: themeConfig.textSecondary }">/</text>
<text class="exp-total" :style="{ color: themeConfig.textSecondary }">{{ growthInfo.todayProgress.maxDailyExp || 120 }}</text>
<text class="exp-unit-text" :style="{ color: themeConfig.textSecondary }">EXP</text>
</view>
<view class="progress-bar-small" :style="{ background: themeConfig.borderLight }">
<view class="progress-fill-small" :style="{ width: dailyExpPercent + '%', background: themeConfig.gradientSoft }"></view>
</view>
</view>
<view class="task-list">
<view class="task-item" v-for="(item, index) in todayProgressItems" :key="index">
<view class="task-icon-box" :style="{ background: item.bgColor }">
<FaIcon :icon="item.iconName" :size="20" :color="item.color" />
</view>
<view class="task-info">
<text class="task-name" :style="{ color: themeConfig.textPrimary }">{{ item.name }}</text>
<text class="task-detail" :style="{ color: themeConfig.textSecondary }">{{ item.detail }}</text>
</view>
<text class="task-value" :style="{ color: item.color }">{{ item.value }}</text>
</view>
</view>
</view>
<view class="module-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 8rpx 32rpx ${themeConfig.shadowLight}` }">
<view class="module-header">
<view class="module-icon-wrap" :style="{ background: `${themeConfig.warningColor}15` }">
<FaIcon icon="medal" :size="22" :color="themeConfig.warningColor" />
</view>
<view class="module-title-wrap">
<text class="module-title" :style="{ color: themeConfig.textPrimary }">成就</text>
<text class="achievement-count" :style="{ color: themeConfig.primaryColor }">{{ achievementsData.unlocked.length }}/{{ achievementsData.unlocked.length + achievementsData.locked.length }}</text>
</view>
</view>
<view class="tab-group">
<view
class="tab-item"
:class="{ active: activeTab === 'unlocked' }"
:style="activeTab === 'unlocked' ? { background: themeConfig.gradientSoft, color: '#fff', boxShadow: `0 8rpx 24rpx ${themeConfig.shadowColor}` } : { background: `${themeConfig.textPrimary}05`, color: themeConfig.textSecondary }"
@click="activeTab = 'unlocked'"
>
已解锁
<view class="tab-badge" :style="activeTab === 'unlocked' ? { background: 'rgba(255,255,255,0.2)' } : {}">{{ achievementsData.unlocked.length }}</view>
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'locked' }"
:style="activeTab === 'locked' ? { background: themeConfig.gradientSoft, color: '#fff', boxShadow: `0 8rpx 24rpx ${themeConfig.shadowColor}` } : { background: `${themeConfig.textPrimary}05`, color: themeConfig.textSecondary }"
@click="activeTab = 'locked'"
>
未解锁
<view class="tab-badge" :style="activeTab === 'locked' ? { background: 'rgba(255,255,255,0.2)' } : {}">{{ achievementsData.locked.length }}</view>
</view>
</view>
<view class="achievement-grid">
<view class="achievement-card"
v-for="(item, index) in (activeTab === 'unlocked' ? achievementsData.unlocked : achievementsData.locked)"
:key="item.id || index"
:style="{ animationDelay: index * 0.08 + 's' }"
>
<view class="achievement-top">
<view class="achievement-avatar" :style="activeTab === 'unlocked' ? { background: getCategoryBg(item.category) } : { background: themeConfig.borderLight }">
<text class="achievement-emoji" :class="{ 'is-locked': activeTab === 'locked' }">{{ item.icon }}</text>
<view class="achievement-glow" v-if="activeTab === 'unlocked'"></view>
</view>
<view class="achievement-category-tag" :style="{ background: getCategoryBg(item.category), color: getCategoryColor(item.category) }">
<text>{{ getCategoryName(item.category) }}</text>
</view>
</view>
<view class="achievement-text">
<text class="achievement-name" :style="{ color: activeTab === 'unlocked' ? themeConfig.textPrimary : themeConfig.textSecondary }">{{ item.name }}</text>
<text class="achievement-desc" :style="{ color: themeConfig.textSecondary }">{{ item.description }}</text>
</view>
<view class="achievement-reward" :style="activeTab === 'unlocked' ? { background: themeConfig.gradientSoft, color: '#fff' } : { background: themeConfig.borderLight, color: themeConfig.textSecondary }">
<text>+{{ item.expReward }} EXP</text>
</view>
</view>
</view>
<EmptyState v-if="(activeTab === 'unlocked' ? achievementsData.unlocked : achievementsData.locked).length === 0" type="default" :title="activeTab === 'unlocked' ? '暂无已解锁成就' : '暂无未解锁成就'" :subtitle="activeTab === 'unlocked' ? '完成今日任务开始解锁成就吧' : '所有成就都已解锁,继续保持'" compact />
</view>
<view class="module-card" :style="{ background: themeConfig.cardBgColor, boxShadow: `0 8rpx 32rpx ${themeConfig.shadowLight}` }">
<view class="module-header">
<view class="module-icon-wrap" :style="{ background: `${themeConfig.dangerColor}15` }">
<FaIcon icon="gift" :size="22" :color="themeConfig.dangerColor" />
</view>
<text class="module-title" :style="{ color: themeConfig.textPrimary }">特权</text>
</view>
<view class="privilege-section">
<view class="section-label">
<text class="label-text" :style="{ color: themeConfig.textSecondary }">当前等级特权</text>
<view class="section-tag active" :style="{ background: `${themeConfig.successColor}15`, color: themeConfig.successColor }">
<text>已解锁</text>
</view>
</view>
<view class="privilege-tags">
<view class="privilege-tag" v-for="(privilege, index) in growthInfo.currentPrivileges" :key="index" :style="{ background: `${themeConfig.successColor}10` }">
<FaIcon icon="check" :size="14" :color="themeConfig.successColor" />
<text class="tag-text" :style="{ color: themeConfig.textPrimary }">{{ privilege }}</text>
</view>
<view class="privilege-tag empty" v-if="!growthInfo.currentPrivileges || growthInfo.currentPrivileges.length === 0" :style="{ background: themeConfig.borderLight }">
<text class="tag-text" :style="{ color: themeConfig.textSecondary }">暂无特权</text>
</view>
</view>
</view>
<view class="privilege-section next-level" v-if="growthInfo.nextLevelPrivileges && growthInfo.nextLevelPrivileges.length > 0">
<view class="section-label">
<text class="label-text" :style="{ color: themeConfig.textSecondary }">下一级特权</text>
<view class="section-tag locked" :style="{ background: `${themeConfig.textPrimary}08`, color: themeConfig.textSecondary }">
<FaIcon icon="lock" :size="14" :color="themeConfig.textSecondary" />
<text style="margin-left: 6rpx;">即将解锁</text>
</view>
</view>
<view class="privilege-tags">
<view class="privilege-tag locked" v-for="(privilege, index) in growthInfo.nextLevelPrivileges" :key="index" :style="{ background: themeConfig.borderLight }">
<text class="tag-text" :style="{ color: themeConfig.textSecondary }">{{ privilege }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-padding"></view>
</scroll-view>
</view>
</template>
<script>
import { getThemeConfigFromStorage, THEME_PRESETS } from '@/utils/theme'
import { refreshNavBarTheme, forceApplyTabBarTheme } from '@/utils/themeGlobal'
import { getGrowthInfo, getGrowthAchievements } from '@/api/index'
import FaIcon from '@/components/FaIcon.vue'
import EmptyState from '@/components/EmptyState.vue'
export default {
components: { FaIcon, EmptyState },
data() {
return {
themeConfig: getThemeConfigFromStorage() || THEME_PRESETS['default'],
todayDate: '',
activeTab: 'unlocked',
achievementsData: {
unlocked: [],
locked: []
},
animatedProgress: 0,
animatedExp: 0,
growthInfo: {
currentLevel: 1,
levelTitle: '记录小白',
levelIcon: '',
levelColor: '#94A3B8',
totalExp: 0,
nextLevelExp: 200,
levelProgress: 0,
totalDays: 0,
streakDays: 0,
maxStreakDays: 0,
todayExp: 0,
todayProgress: {
tasksCompleted: 0,
taskExp: 0,
billsRecorded: 0,
billExp: 0,
moodRecorded: false,
moodExp: 0,
totalExp: 0,
maxDailyExp: 120
},
currentPrivileges: [],
nextLevelPrivileges: []
}
}
},
computed: {
expPercent() {
if (!this.growthInfo.nextLevelExp || this.growthInfo.nextLevelExp <= 0) return 0
return Math.min(100, Math.round((this.growthInfo.totalExp / this.growthInfo.nextLevelExp) * 100))
},
dailyExpPercent() {
return Math.min(100, Math.round((this.growthInfo.todayExp / 150) * 100))
},
todayProgressItems() {
return [
{
iconName: 'list-check',
name: '任务完成',
detail: `+${this.growthInfo.todayProgress.taskExp} EXP`,
value: `${this.growthInfo.todayProgress.tasksCompleted}个`,
color: this.themeConfig.primaryColor,
bgColor: `${this.themeConfig.primaryColor}08`
},
{
iconName: 'money-bill',
name: '账单记录',
detail: `+${this.growthInfo.todayProgress.billExp} EXP`,
value: `${this.growthInfo.todayProgress.billsRecorded}笔`,
color: '#f59e0b',
bgColor: 'rgba(245, 158, 11, 0.08)'
},
{
iconName: this.growthInfo.todayProgress.moodRecorded ? 'face-smile' : 'calendar-xmark',
name: '心情记录',
detail: `+${this.growthInfo.todayProgress.moodExp} EXP`,
value: this.growthInfo.todayProgress.moodRecorded ? '已完成' : '待记录',
color: '#ec4899',
bgColor: 'rgba(236, 72, 153, 0.08)'
},
{
iconName: this.growthInfo.todayProgress.reviewRecorded ? 'face-smile' : 'calendar-xmark',
name: '复盘记录',
detail: `+${this.growthInfo.todayProgress.reviewExp} EXP`,
value: this.growthInfo.todayProgress.reviewRecorded ? '已完成' : '待记录',
color: '#673ab7',
bgColor: 'rgba(153, 72, 153, 0.08)'
}
]
}
},
onLoad() {
this.setTodayDate()
this.loadGrowthInfo()
this.loadAchievements()
this.themeChangeHandler = (themeData) => {
let newTheme = null
if (themeData?.key && THEME_PRESETS[themeData.key]) {
newTheme = THEME_PRESETS[themeData.key]
} else if (themeData?.config) {
newTheme = themeData.config
}
if (newTheme) {
this.themeConfig = newTheme
}
}
uni.$on('themeChanged', this.themeChangeHandler)
},
onShow() {
const latestTheme = getThemeConfigFromStorage() || THEME_PRESETS['default']
if (JSON.stringify(latestTheme) !== JSON.stringify(this.themeConfig)) {
this.themeConfig = latestTheme
}
refreshNavBarTheme()
setTimeout(() => {
forceApplyTabBarTheme(latestTheme)
}, 100)
},
onUnload() {
uni.$off('themeChanged', this.themeChangeHandler)
},
methods: {
setTodayDate() {
const now = new Date()
const month = now.getMonth() + 1
const day = now.getDate()
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekDay = weekDays[now.getDay()]
this.todayDate = `${month}${day}${weekDay}`
},
async loadGrowthInfo() {
try {
const res = await getGrowthInfo()
if (res) {
const data = res.data || res
this.growthInfo = {
currentLevel: data.level || data.currentLevel || 1,
levelTitle: data.title || data.levelTitle || '记录小白',
levelIcon: data.titleIcon || data.levelIcon || '',
levelColor: data.color || data.levelColor || '#94A3B8',
totalExp: data.experience || data.totalExp || 0,
nextLevelExp: data.nextLevelExp || 200,
levelProgress: data.levelProgress || 0,
totalDays: data.totalDays || 0,
streakDays: data.streakDays || 0,
maxStreakDays: data.maxStreakDays || 0,
todayExp: data.todayExp || (data.todayProgress ? data.todayProgress.billExp + data.todayProgress.taskExp + data.todayProgress.moodExp + data.todayProgress.reviewExp: 0),
todayProgress: {
tasksCompleted: data.todayProgress?.tasksCompleted || 0,
taskExp: data.todayProgress?.taskExp || 0,
billsRecorded: data.todayProgress?.billsRecorded || 0,
billExp: data.todayProgress?.billExp || 0,
moodRecorded: data.todayProgress?.moodRecorded || false,
moodExp: data.todayProgress?.moodExp || 0,
reviewRecorded: data.todayProgress?.reviewRecorded || false,
reviewExp: data.todayProgress?.reviewExp || 0,
totalExp: data.todayProgress?.totalExp || 0,
maxDailyExp: data.todayProgress?.maxDailyExp || 120
},
currentPrivileges: data.currentPrivileges || data.privileges || [],
nextLevelPrivileges: data.nextLevelPrivileges || []
}
this.animateProgress()
this.animateExp()
}
} catch (e) {
console.log('加载成长信息失败:', e)
}
},
animateProgress() {
const targetProgress = this.growthInfo.levelProgress || this.expPercent
const duration = 1000
const startTime = Date.now()
const startValue = 0
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
const easeOut = 1 - Math.pow(1 - progress, 3)
this.animatedProgress = Math.round(startValue + (targetProgress - startValue) * easeOut)
if (progress < 1) {
setTimeout(animate, 16)
}
}
setTimeout(() => {
animate()
}, 300)
},
animateExp() {
const targetExp = this.growthInfo.totalExp
const duration = 800
const startTime = Date.now()
const startValue = 0
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
const easeOut = 1 - Math.pow(1 - progress, 3)
this.animatedExp = Math.round(startValue + (targetExp - startValue) * easeOut)
if (progress < 1) {
setTimeout(animate, 16)
}
}
setTimeout(() => {
animate()
}, 300)
},
async loadAchievements() {
try {
const res = await getGrowthAchievements()
if (res) {
const data = res.data || res
this.achievementsData = {
unlocked: this.formatAchievements(data.unlockedList || data.unlocked || []),
locked: this.formatAchievements(data.lockedList || data.locked || [])
}
}
} catch (e) {
console.log('加载成就列表失败:', e)
}
},
formatAchievements(list) {
return list.map(item => {
// 后端直接返回 emoji 字符作为 icon 字段
const iconChar = item.icon && typeof item.icon === 'string' && item.icon.length > 0 ? item.icon : '🏆'
return {
id: item.id || (item.category || 'ach') + '_' + Math.random().toString(36).slice(2, 8),
icon: iconChar,
name: item.name || item.title || this.getCategoryName(item.category),
description: item.description || item.desc,
expReward: item.expReward || item.exp || item.reward || 0,
category: item.category,
rarity: item.rarity || '初级'
}
})
},
getCategoryIcon(category) {
// 返回 emoji 字符作为兜底分类图标
const icons = {
bill: '💰',
task: '📋',
mood: '🌈',
streak: '🔥',
review: '✨',
default: '🏆'
}
return icons[category] || icons.default
},
getCategoryName(category) {
const names = {
bill: '账单达人',
task: '任务能手',
mood: '心情记录',
streak: '连续之星',
review: '复盘感悟',
default: '成就'
}
return names[category] || names.default
},
getCategoryColor(category) {
const map = {
streak: '#F59E0B',
task: '#9B8AFB',
mood: '#EC4899',
bill: '#4ECDC4',
review: '#7B6CEB'
}
return map[category] || this.themeConfig.primaryColor
},
getCategoryBg(category) {
const map = {
streak: 'rgba(245, 158, 11, 0.15)',
task: 'rgba(155, 138, 251, 0.15)',
mood: 'rgba(236, 72, 153, 0.15)',
bill: 'rgba(78, 205, 196, 0.15)',
review: 'rgba(123, 108, 235, 0.15)'
}
return map[category] || 'rgba(155, 138, 251, 0.12)'
},
goToRules() {
uni.navigateTo({
url: '/pages/growth/rules'
})
}
}
}
</script>
<style lang="scss" scoped>
.growth-page {
min-height: 100vh;
position: relative;
overflow: hidden;
}
.bg-decoration {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.deco-blob {
position: absolute;
border-radius: 50%;
filter: blur(120rpx);
opacity: 0.7;
}
.deco-1 {
width: 550rpx;
height: 550rpx;
top: -150rpx;
right: -120rpx;
}
.deco-2 {
width: 450rpx;
height: 450rpx;
bottom: 280rpx;
left: -100rpx;
}
.deco-3 {
width: 350rpx;
height: 350rpx;
top: 380rpx;
right: -80rpx;
}
.scroll-container {
height: 100vh;
position: relative;
z-index: 5;
}
.header-card {
margin: 20rpx 24rpx;
border-radius: 12rpx;
padding: 32rpx;
position: relative;
overflow: hidden;
}
.card-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.bg-orb {
position: absolute;
width: 350rpx;
height: 350rpx;
border-radius: 50%;
background: radial-gradient(circle at 40% 40%, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0.1) 30%, transparent 70%);
top: -180rpx;
right: -100rpx;
filter: blur(20rpx);
animation: orbFloat 8s ease-in-out infinite;
}
@keyframes orbFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-20rpx, 15rpx) scale(1.08); }
}
.bg-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 32rpx 32rpx;
}
.bg-float-shape {
position: absolute;
border-radius: 50%;
opacity: 0.5;
}
.shape-1 {
width: 220rpx;
height: 220rpx;
top: -100rpx;
left: -80rpx;
animation: shapeFloat1 8s ease-in-out infinite;
}
.shape-2 {
width: 180rpx;
height: 180rpx;
top: 40rpx;
right: 80rpx;
animation: shapeFloat2 7s ease-in-out infinite;
}
@keyframes shapeFloat1 {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(15rpx, -12rpx) rotate(8deg); }
}
@keyframes shapeFloat2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-10rpx, 15rpx) scale(1.1); }
}
.card-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.user-card {
display: flex;
align-items: center;
gap: 22rpx;
}
.avatar-ring {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid rgba(255, 255, 255, 0.3);
transition: transform 0.3s ease;
}
.avatar-ring:active {
transform: scale(0.95);
}
.avatar-emoji {
display: flex;
align-items: center;
justify-content: center;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.user-name {
font-size: 32rpx;
font-weight: 700;
color: #fff;
}
.level-badge {
display: inline-flex;
padding: 4rpx 18rpx;
border-radius: 100rpx;
width: fit-content;
backdrop-filter: blur(10px);
}
.level-badge text {
font-size: 22rpx;
font-weight: 600;
color: #fff;
}
.exp-section {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.exp-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.exp-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.85);
}
.exp-value {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.exp-current {
font-size: 36rpx;
font-weight: 700;
color: #fff;
}
.exp-divider {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.exp-max {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
}
.progress-wrapper {
display: flex;
align-items: center;
gap: 16rpx;
}
.progress-bar {
flex: 1;
height: 14rpx;
border-radius: 100rpx;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
border-radius: 100rpx;
transition: width 1s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
overflow: hidden;
}
.progress-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: progressShine 2s ease-in-out infinite;
}
@keyframes progressShine {
0% { left: -100%; }
100% { left: 100%; }
}
.progress-text {
font-size: 24rpx;
font-weight: 600;
color: #fff;
min-width: 60rpx;
text-align: right;
}
.content-area {
padding: 0 24rpx;
}
.stats-row {
display: flex;
gap: 14rpx;
margin-bottom: 20rpx;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
padding: 24rpx 12rpx;
border-radius: 22rpx;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.stat-item:active {
transform: scale(0.96);
}
.stat-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.stat-item:active .stat-icon-wrap {
transform: scale(1.1);
}
.stat-icon {
font-size: 36rpx;
}
.stat-num {
font-size: 36rpx;
font-weight: 700;
}
.stat-label {
font-size: 22rpx;
}
.rules-entry {
display: flex;
align-items: center;
gap: 18rpx;
padding: 24rpx;
border-radius: 22rpx;
margin-bottom: 20rpx;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.rules-entry:active {
transform: scale(0.98);
}
.entry-icon {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
}
.entry-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.entry-title {
font-size: 30rpx;
font-weight: 700;
}
.entry-desc {
font-size: 24rpx;
}
.entry-arrow {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
}
.module-card {
border-radius: 12rpx;
padding: 28rpx;
margin-bottom: 20rpx;
}
.module-header {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 24rpx;
}
.module-icon-wrap {
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.module-icon {
font-size: 28rpx;
}
.module-title-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
}
.module-title {
font-size: 30rpx;
font-weight: 700;
}
.module-subtitle {
font-size: 24rpx;
}
.achievement-count {
font-size: 26rpx;
font-weight: 700;
}
.today-exp {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-bottom: 24rpx;
padding-bottom: 24rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
}
.exp-display {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.exp-number {
font-size: 48rpx;
font-weight: 700;
font-weight: 800;
}
.exp-slash {
font-size: 30rpx;
}
.exp-total {
font-size: 30rpx;
}
.exp-unit-text {
font-size: 24rpx;
margin-left: 6rpx;
}
.progress-bar-small {
height: 10rpx;
border-radius: 100rpx;
overflow: hidden;
}
.progress-fill-small {
height: 100%;
border-radius: 100rpx;
transition: width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.task-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.task-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx;
background: rgba(0, 0, 0, 0.02);
border-radius: 16rpx;
}
.task-icon-box {
width: 56rpx;
height: 56rpx;
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
flex-shrink: 0;
}
.task-info {
flex: 1;
}
.task-name {
font-size: 26rpx;
font-weight: 600;
display: block;
}
.task-detail {
font-size: 22rpx;
margin-top: 4rpx;
}
.task-value {
font-size: 26rpx;
font-weight: 600;
}
.tab-group {
display: flex;
gap: 14rpx;
margin-bottom: 24rpx;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 18rpx;
border-radius: 16rpx;
font-size: 26rpx;
font-weight: 600;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tab-item:active {
transform: scale(0.96);
}
.tab-badge {
padding: 4rpx 12rpx;
border-radius: 100rpx;
font-size: 20rpx;
font-weight: 700;
}
.achievement-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.achievement-card {
position: relative;
display: flex;
flex-direction: column;
gap: 14rpx;
padding: 24rpx;
background: rgba(0, 0, 0, 0.03);
border-radius: 20rpx;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
animation: cardFadeIn 0.5s ease-out forwards;
opacity: 0;
transform: translateY(20rpx);
min-height: 200rpx;
}
@keyframes cardFadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
.achievement-card:active {
transform: scale(0.97);
background: rgba(0, 0, 0, 0.05);
}
.achievement-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10rpx;
}
.achievement-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.achievement-emoji {
font-size: 44rpx;
line-height: 1;
display: block;
}
.achievement-emoji.is-locked {
filter: grayscale(0.85);
opacity: 0.6;
}
.achievement-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
animation: glowRotate 4s linear infinite;
pointer-events: none;
}
@keyframes glowRotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.achievement-category-tag {
padding: 4rpx 12rpx;
border-radius: 100rpx;
font-size: 18rpx;
font-weight: 600;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.achievement-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.achievement-name {
font-size: 26rpx;
font-weight: 700;
display: block;
line-height: 1.3;
}
.achievement-desc {
font-size: 22rpx;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.achievement-reward {
padding: 8rpx 18rpx;
border-radius: 100rpx;
font-size: 22rpx;
font-weight: 600;
text-align: center;
width: fit-content;
}
.privilege-section {
margin-bottom: 24rpx;
}
.privilege-section.next-level {
padding-top: 24rpx;
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
margin-bottom: 0;
}
.section-label {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 18rpx;
}
.label-text {
font-size: 24rpx;
}
.section-tag {
padding: 6rpx 14rpx;
border-radius: 100rpx;
font-size: 22rpx;
font-weight: 500;
}
.privilege-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.privilege-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
border-radius: 100rpx;
}
.privilege-tag.empty,
.privilege-tag.locked {
background: rgba(0, 0, 0, 0.03);
}
.tag-check {
font-size: 22rpx;
font-weight: 700;
}
.tag-text {
font-size: 24rpx;
}
.privilege-tag.empty .tag-text,
.privilege-tag.locked .tag-text {
color: #999;
}
.bottom-padding {
height: 60rpx;
}
</style>