hookehuyr

feat(ranking): 添加数字滚动动画组件并集成到排行榜卡片

在排行榜卡片中添加数字滚动动画效果,当用户滚动到排行榜区域时触发动画
新增 NumberRoll 组件实现数字滚动效果
在 Dashboard 页面添加滚动监听以触发动画
调整排行榜卡片样式和间距
......@@ -14,6 +14,7 @@ declare module 'vue' {
FamilyAlbum: typeof import('./src/components/FamilyAlbum.vue')['default']
GlassCard: typeof import('./src/components/GlassCard.vue')['default']
NavBar: typeof import('./src/components/navBar.vue')['default']
NumberRoll: typeof import('./src/components/NumberRoll.vue')['default']
NutActionSheet: typeof import('@nutui/nutui-taro')['ActionSheet']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutCol: typeof import('@nutui/nutui-taro')['Col']
......
<template>
<view class="number-roll-container">
<view
v-for="(digit, index) in digits"
:key="index"
class="digit-container"
>
<view class="digit-wrapper">
<view
class="digit-column"
:style="{ transform: `translateY(-${digit.currentValue * 10}%)` }"
>
<view
v-for="num in 10"
:key="num - 1"
class="digit-item"
>
{{ num - 1 }}
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, defineExpose } from 'vue'
// Props
const props = defineProps({
// 目标数值
targetValue: {
type: Number,
default: 0
},
// 数字位数
digitCount: {
type: Number,
default: 4
},
// 滚动速度(毫秒)
duration: {
type: Number,
default: 2000
},
// 每位数字之间的延迟(毫秒)
digitDelay: {
type: Number,
default: 200
}
})
// 响应式数据
const isAnimating = ref(false)
const isPaused = ref(false)
const animationTimers = ref([])
// 初始化数字数组
const digits = ref([])
// 初始化数字位
const initDigits = () => {
digits.value = Array.from({ length: props.digitCount }, (_, index) => ({
currentValue: 0,
targetValue: 0,
animationId: null,
startTime: 0,
pausedTime: 0
}))
}
// 将数字转换为数组
const parseTargetValue = (value) => {
const str = value.toString().padStart(props.digitCount, '0')
return str.split('').map(Number)
}
// 开始滚动动画
const startAnimation = () => {
if (isAnimating.value && !isPaused.value) return
isAnimating.value = true
isPaused.value = false
const targetDigits = parseTargetValue(props.targetValue)
// 为每个数字位设置目标值
digits.value.forEach((digit, index) => {
digit.targetValue = targetDigits[index]
})
// 依次启动每个数字位的动画
digits.value.forEach((digit, index) => {
const delay = index * props.digitDelay
setTimeout(() => {
if (!isAnimating.value || isPaused.value) return
animateDigit(digit, index)
}, delay)
})
}
// 单个数字位的动画
const animateDigit = (digit, index) => {
const startValue = digit.currentValue
const endValue = digit.targetValue
const startTime = Date.now()
digit.startTime = startTime
const animate = () => {
if (!isAnimating.value || isPaused.value) return
const currentTime = Date.now()
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / props.duration, 1)
// 使用缓动函数
const easeProgress = easeOutCubic(progress)
// 计算当前值
const currentValue = startValue + (endValue - startValue) * easeProgress
digit.currentValue = currentValue
if (progress < 1) {
digit.animationId = requestAnimationFrame(animate)
} else {
digit.currentValue = endValue
checkAnimationComplete()
}
}
digit.animationId = requestAnimationFrame(animate)
}
// 缓动函数
const easeOutCubic = (t) => {
return 1 - Math.pow(1 - t, 3)
}
// 检查动画是否完成
const checkAnimationComplete = () => {
const allComplete = digits.value.every(digit =>
Math.abs(digit.currentValue - digit.targetValue) < 0.01
)
if (allComplete) {
isAnimating.value = false
// 确保所有数字都是整数
digits.value.forEach(digit => {
digit.currentValue = digit.targetValue
})
}
}
// 暂停动画
const pauseAnimation = () => {
if (!isAnimating.value || isPaused.value) return
isPaused.value = true
digits.value.forEach(digit => {
if (digit.animationId) {
cancelAnimationFrame(digit.animationId)
digit.animationId = null
digit.pausedTime = Date.now()
}
})
}
// 恢复动画
const resumeAnimation = () => {
if (!isAnimating.value || !isPaused.value) return
isPaused.value = false
digits.value.forEach((digit, index) => {
if (digit.currentValue !== digit.targetValue) {
// 调整开始时间以补偿暂停的时间
const pausedDuration = digit.pausedTime - digit.startTime
digit.startTime = Date.now() - pausedDuration
animateDigit(digit, index)
}
})
}
// 重置动画
const resetAnimation = () => {
isAnimating.value = false
isPaused.value = false
// 取消所有动画
digits.value.forEach(digit => {
if (digit.animationId) {
cancelAnimationFrame(digit.animationId)
digit.animationId = null
}
digit.currentValue = 0
digit.targetValue = 0
digit.startTime = 0
digit.pausedTime = 0
})
}
// 监听目标值变化
watch(() => props.targetValue, () => {
if (isAnimating.value) {
resetAnimation()
}
})
// 初始化
initDigits()
// 暴露方法
defineExpose({
start: startAnimation,
pause: pauseAnimation,
resume: resumeAnimation,
reset: resetAnimation,
isAnimating: computed(() => isAnimating.value),
isPaused: computed(() => isPaused.value)
})
</script>
<style lang="less">
.number-roll-container {
display: flex;
align-items: center;
justify-content: center;
gap: 4rpx;
}
.digit-container {
position: relative;
width: 40rpx;
height: 60rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
border-radius: 8rpx;
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.digit-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.digit-column {
position: absolute;
top: 0;
left: 0;
width: 100%;
transition: transform 0.1s ease-out;
}
.digit-item {
width: 100%;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: bold;
color: #fff;
font-family: 'Courier New', monospace;
}
</style>
<!--
* @Date: 2025-01-09 00:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-11 13:50:59
* @LastEditTime: 2025-10-25 18:26:37
* @FilePath: /lls_program/src/components/RankingCard.vue
* @Description: 排行榜卡片组件
-->
......@@ -46,6 +46,20 @@
<view v-if="activeTab === 'support'" class="absolute font-bold text-white bg-orange-500 top-0 rounded-full px-4 py-1" style="right: 30rpx; font-size: 23rpx;" @tap="joinOrganization">助力码</view>
</view>
<view class="relative mb-2 text-white">
<view class="flex items-center justify-center">
<text class="mr-1" style="font-size: 28rpx;">总数: </text>
<NumberRoll
ref="numberRollRef"
:target-value="currentTotalFamilySum"
:digit-count="currentTotalFamilySum.length"
:duration="1500"
:digit-delay="150"
/>
<text class="mr-1" style="font-size: 28rpx;">个家庭</text>
</view>
</view>
<!-- 助力榜空状态提示 -->
<view v-if="activeTab === 'support' && (!supportData || !supportData.families || supportData.families.length === 0)" class="support-empty-state">
<view class="empty-image">
......@@ -174,9 +188,10 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import Taro from '@tarojs/taro'
import { IconFont } from '@nutui/icons-vue-taro';
import NumberRoll from './NumberRoll.vue'
// 默认头像
const defaultAvatar = 'https://cdn.ipadbiz.cn/lls_prog/images/%E5%85%A8%E5%AE%B6%E7%A6%8F3_%E5%89%AF%E6%9C%AC.jpg?imageMogr2/strip/quality/60'
// 助力榜图片
......@@ -228,7 +243,11 @@ const loading = ref(false)
// 排行榜日期
const currentDate = ref('')
// 当前总家庭数
const currentTotalFamilySum = ref(0);
// 数字滚动组件引用
const numberRollRef = ref(null)
/**
* 切换tab
......@@ -281,6 +300,9 @@ const loadLeaderboardData = async (isInitialLoad = false) => {
}
// 设置当前日期
currentDate.value = response.data.yesterday
// TODO: 设置总家庭数
const newTotalFamilySum = 2318;
currentTotalFamilySum.value = newTotalFamilySum;
}
} catch (error) {
console.error('获取排行榜数据失败:', error)
......@@ -465,7 +487,17 @@ onMounted(async () => {
// 暴露刷新方法给父组件
defineExpose({
refreshData
refreshData,
startNumberRoll: () => {
if (numberRollRef.value) {
numberRollRef.value.start()
}
},
resetNumberRoll: () => {
if (numberRollRef.value) {
numberRollRef.value.reset()
}
}
})
</script>
......@@ -582,7 +614,7 @@ defineExpose({
color: rgba(255, 255, 255, 0.8);
text-align: center;
margin-top: 40rpx;
margin-bottom: 40rpx;
margin-bottom: 10rpx;
}
}
......
<!--
* @Date: 2025-08-27 17:43:45
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-01 11:30:56
* @LastEditTime: 2025-10-25 18:22:46
* @FilePath: /lls_program/src/pages/Dashboard/index.vue
* @Description: 首页
-->
......@@ -149,7 +149,9 @@
</WeRunAuth>
<!-- 排行榜卡片 -->
<view id="ranking-card-container" ref="rankingCardContainer">
<RankingCard ref="rankingCardRef" :onViewMore="openFamilyRank" />
</view>
<!-- Family album -->
<FamilyAlbum
......@@ -172,7 +174,7 @@
<script setup>
import "./index.less";
import { ref, computed, onMounted, onUnmounted } from 'vue';
import Taro, { useDidShow, useReady, useLoad } from '@tarojs/taro';
import Taro, { useDidShow, useReady, useLoad, usePageScroll } from '@tarojs/taro';
import { handleSharePageAuth, addShareFlag } from '@/utils/authRedirect';
import { Setting, Photograph, IconFont } from '@nutui/icons-vue-taro';
import BottomNav from '@/components/BottomNav.vue';
......@@ -195,6 +197,7 @@ const isWeRunAuthorized = ref(false);
const pointsCollectorRef = ref(null)
const weRunAuthRef = ref(null)
const rankingCardRef = ref(null)
const rankingCardContainer = ref(null)
const showTotalPointsOnly = ref(false)
const finalTotalPoints = ref(0)
const pendingPoints = ref([]) // 待收集的积分数据
......@@ -302,19 +305,33 @@ const handleSyncFailed = (data) => {
console.log('微信步数同步失败:', data)
}
// 滚动监听相关
const hasTriggeredNumberRoll = ref(false)
// 监听全局的微信步数同步成功事件(用于处理401重试后的成功情况)
onMounted(() => {
if (Taro.eventCenter) {
Taro.eventCenter.on('wx-steps-sync-success', handleStepsSynced);
}
// 设置滚动监听(实际通过 usePageScroll 实现)
setupScrollListener()
});
onUnmounted(() => {
if (Taro.eventCenter) {
Taro.eventCenter.off('wx-steps-sync-success', handleStepsSynced);
}
// 清理滚动监听
cleanupScrollListener()
});
// 使用 usePageScroll 监听页面滚动
usePageScroll((res) => {
handlePageScroll(res.scrollTop)
})
/**
* 计算总步数(包含用户步数和家庭成员步数)
* @returns {string} 格式化后的总步数
......@@ -588,4 +605,82 @@ const isTabletDevice = computed(() => {
// 普通手机设备比例通常在 0.4-0.6 之间
return screenRatio > 0.65;
});
/**
* 设置滚动监听
*/
const setupScrollListener = () => {
// 使用 usePageScroll 监听页面滚动
// 注意:usePageScroll 需要在组件顶层调用
}
/**
* 清理滚动监听
*/
const cleanupScrollListener = () => {
// usePageScroll 会自动清理,无需手动清理
}
/**
* 处理页面滚动事件
* @param {number} scrollTop - 滚动距离
*/
const handlePageScroll = (scrollTop) => {
// 如果已经触发过数字滚动,则不再处理
if (hasTriggeredNumberRoll.value) {
return
}
// console.log('页面滚动:', scrollTop)
// 检查 RankingCard 组件是否进入视窗
if (rankingCardContainer.value) {
Taro.createSelectorQuery()
.select('#ranking-card-container')
.boundingClientRect((rect) => {
if (rect) {
// console.log('RankingCard 位置信息:', rect)
// 获取系统信息
Taro.getSystemInfo({
success: (res) => {
const windowHeight = res.windowHeight
// 当组件顶部进入视窗下方80%位置时触发
const triggerPoint = windowHeight * 0.8
// console.log('窗口高度:', windowHeight, '触发点:', triggerPoint, 'rect.top:', rect.top)
if (rect.top <= triggerPoint && rect.bottom >= 0) {
// console.log('触发数字滚动动画!')
// 触发数字滚动动画
triggerNumberRoll()
}
}
})
} else {
console.log('未找到 RankingCard 容器元素')
}
})
.exec()
} else {
console.log('rankingCardContainer.value 为空')
}
}
/**
* 触发数字滚动动画
*/
const triggerNumberRoll = () => {
if (hasTriggeredNumberRoll.value) {
return
}
hasTriggeredNumberRoll.value = true
// 延迟一点时间再触发,让用户看到滚动效果
setTimeout(() => {
if (rankingCardRef.value && rankingCardRef.value.startNumberRoll) {
rankingCardRef.value.startNumberRoll()
}
}, 300)
}
</script>
......