hookehuyr

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

在排行榜卡片中添加数字滚动动画效果,当用户滚动到排行榜区域时触发动画
新增 NumberRoll 组件实现数字滚动效果
在 Dashboard 页面添加滚动监听以触发动画
调整排行榜卡片样式和间距
...@@ -14,6 +14,7 @@ declare module 'vue' { ...@@ -14,6 +14,7 @@ declare module 'vue' {
14 FamilyAlbum: typeof import('./src/components/FamilyAlbum.vue')['default'] 14 FamilyAlbum: typeof import('./src/components/FamilyAlbum.vue')['default']
15 GlassCard: typeof import('./src/components/GlassCard.vue')['default'] 15 GlassCard: typeof import('./src/components/GlassCard.vue')['default']
16 NavBar: typeof import('./src/components/navBar.vue')['default'] 16 NavBar: typeof import('./src/components/navBar.vue')['default']
17 + NumberRoll: typeof import('./src/components/NumberRoll.vue')['default']
17 NutActionSheet: typeof import('@nutui/nutui-taro')['ActionSheet'] 18 NutActionSheet: typeof import('@nutui/nutui-taro')['ActionSheet']
18 NutButton: typeof import('@nutui/nutui-taro')['Button'] 19 NutButton: typeof import('@nutui/nutui-taro')['Button']
19 NutCol: typeof import('@nutui/nutui-taro')['Col'] 20 NutCol: typeof import('@nutui/nutui-taro')['Col']
......
1 +<template>
2 + <view class="number-roll-container">
3 + <view
4 + v-for="(digit, index) in digits"
5 + :key="index"
6 + class="digit-container"
7 + >
8 + <view class="digit-wrapper">
9 + <view
10 + class="digit-column"
11 + :style="{ transform: `translateY(-${digit.currentValue * 10}%)` }"
12 + >
13 + <view
14 + v-for="num in 10"
15 + :key="num - 1"
16 + class="digit-item"
17 + >
18 + {{ num - 1 }}
19 + </view>
20 + </view>
21 + </view>
22 + </view>
23 + </view>
24 +</template>
25 +
26 +<script setup>
27 +import { ref, computed, watch, defineExpose } from 'vue'
28 +
29 +// Props
30 +const props = defineProps({
31 + // 目标数值
32 + targetValue: {
33 + type: Number,
34 + default: 0
35 + },
36 + // 数字位数
37 + digitCount: {
38 + type: Number,
39 + default: 4
40 + },
41 + // 滚动速度(毫秒)
42 + duration: {
43 + type: Number,
44 + default: 2000
45 + },
46 + // 每位数字之间的延迟(毫秒)
47 + digitDelay: {
48 + type: Number,
49 + default: 200
50 + }
51 +})
52 +
53 +// 响应式数据
54 +const isAnimating = ref(false)
55 +const isPaused = ref(false)
56 +const animationTimers = ref([])
57 +
58 +// 初始化数字数组
59 +const digits = ref([])
60 +
61 +// 初始化数字位
62 +const initDigits = () => {
63 + digits.value = Array.from({ length: props.digitCount }, (_, index) => ({
64 + currentValue: 0,
65 + targetValue: 0,
66 + animationId: null,
67 + startTime: 0,
68 + pausedTime: 0
69 + }))
70 +}
71 +
72 +// 将数字转换为数组
73 +const parseTargetValue = (value) => {
74 + const str = value.toString().padStart(props.digitCount, '0')
75 + return str.split('').map(Number)
76 +}
77 +
78 +// 开始滚动动画
79 +const startAnimation = () => {
80 + if (isAnimating.value && !isPaused.value) return
81 +
82 + isAnimating.value = true
83 + isPaused.value = false
84 +
85 + const targetDigits = parseTargetValue(props.targetValue)
86 +
87 + // 为每个数字位设置目标值
88 + digits.value.forEach((digit, index) => {
89 + digit.targetValue = targetDigits[index]
90 + })
91 +
92 + // 依次启动每个数字位的动画
93 + digits.value.forEach((digit, index) => {
94 + const delay = index * props.digitDelay
95 +
96 + setTimeout(() => {
97 + if (!isAnimating.value || isPaused.value) return
98 + animateDigit(digit, index)
99 + }, delay)
100 + })
101 +}
102 +
103 +// 单个数字位的动画
104 +const animateDigit = (digit, index) => {
105 + const startValue = digit.currentValue
106 + const endValue = digit.targetValue
107 + const startTime = Date.now()
108 +
109 + digit.startTime = startTime
110 +
111 + const animate = () => {
112 + if (!isAnimating.value || isPaused.value) return
113 +
114 + const currentTime = Date.now()
115 + const elapsed = currentTime - startTime
116 + const progress = Math.min(elapsed / props.duration, 1)
117 +
118 + // 使用缓动函数
119 + const easeProgress = easeOutCubic(progress)
120 +
121 + // 计算当前值
122 + const currentValue = startValue + (endValue - startValue) * easeProgress
123 + digit.currentValue = currentValue
124 +
125 + if (progress < 1) {
126 + digit.animationId = requestAnimationFrame(animate)
127 + } else {
128 + digit.currentValue = endValue
129 + checkAnimationComplete()
130 + }
131 + }
132 +
133 + digit.animationId = requestAnimationFrame(animate)
134 +}
135 +
136 +// 缓动函数
137 +const easeOutCubic = (t) => {
138 + return 1 - Math.pow(1 - t, 3)
139 +}
140 +
141 +// 检查动画是否完成
142 +const checkAnimationComplete = () => {
143 + const allComplete = digits.value.every(digit =>
144 + Math.abs(digit.currentValue - digit.targetValue) < 0.01
145 + )
146 +
147 + if (allComplete) {
148 + isAnimating.value = false
149 + // 确保所有数字都是整数
150 + digits.value.forEach(digit => {
151 + digit.currentValue = digit.targetValue
152 + })
153 + }
154 +}
155 +
156 +// 暂停动画
157 +const pauseAnimation = () => {
158 + if (!isAnimating.value || isPaused.value) return
159 +
160 + isPaused.value = true
161 +
162 + digits.value.forEach(digit => {
163 + if (digit.animationId) {
164 + cancelAnimationFrame(digit.animationId)
165 + digit.animationId = null
166 + digit.pausedTime = Date.now()
167 + }
168 + })
169 +}
170 +
171 +// 恢复动画
172 +const resumeAnimation = () => {
173 + if (!isAnimating.value || !isPaused.value) return
174 +
175 + isPaused.value = false
176 +
177 + digits.value.forEach((digit, index) => {
178 + if (digit.currentValue !== digit.targetValue) {
179 + // 调整开始时间以补偿暂停的时间
180 + const pausedDuration = digit.pausedTime - digit.startTime
181 + digit.startTime = Date.now() - pausedDuration
182 + animateDigit(digit, index)
183 + }
184 + })
185 +}
186 +
187 +// 重置动画
188 +const resetAnimation = () => {
189 + isAnimating.value = false
190 + isPaused.value = false
191 +
192 + // 取消所有动画
193 + digits.value.forEach(digit => {
194 + if (digit.animationId) {
195 + cancelAnimationFrame(digit.animationId)
196 + digit.animationId = null
197 + }
198 + digit.currentValue = 0
199 + digit.targetValue = 0
200 + digit.startTime = 0
201 + digit.pausedTime = 0
202 + })
203 +}
204 +
205 +// 监听目标值变化
206 +watch(() => props.targetValue, () => {
207 + if (isAnimating.value) {
208 + resetAnimation()
209 + }
210 +})
211 +
212 +// 初始化
213 +initDigits()
214 +
215 +// 暴露方法
216 +defineExpose({
217 + start: startAnimation,
218 + pause: pauseAnimation,
219 + resume: resumeAnimation,
220 + reset: resetAnimation,
221 + isAnimating: computed(() => isAnimating.value),
222 + isPaused: computed(() => isPaused.value)
223 +})
224 +</script>
225 +
226 +<style lang="less">
227 +.number-roll-container {
228 + display: flex;
229 + align-items: center;
230 + justify-content: center;
231 + gap: 4rpx;
232 +}
233 +
234 +.digit-container {
235 + position: relative;
236 + width: 40rpx;
237 + height: 60rpx;
238 + overflow: hidden;
239 + background: rgba(255, 255, 255, 0.1);
240 + border-radius: 8rpx;
241 + border: 1rpx solid rgba(255, 255, 255, 0.2);
242 +}
243 +
244 +.digit-wrapper {
245 + position: relative;
246 + width: 100%;
247 + height: 100%;
248 + overflow: hidden;
249 +}
250 +
251 +.digit-column {
252 + position: absolute;
253 + top: 0;
254 + left: 0;
255 + width: 100%;
256 + transition: transform 0.1s ease-out;
257 +}
258 +
259 +.digit-item {
260 + width: 100%;
261 + height: 60rpx;
262 + display: flex;
263 + align-items: center;
264 + justify-content: center;
265 + font-size: 32rpx;
266 + font-weight: bold;
267 + color: #fff;
268 + font-family: 'Courier New', monospace;
269 +}
270 +</style>
1 <!-- 1 <!--
2 * @Date: 2025-01-09 00:00:00 2 * @Date: 2025-01-09 00:00:00
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-10-11 13:50:59 4 + * @LastEditTime: 2025-10-25 18:26:37
5 * @FilePath: /lls_program/src/components/RankingCard.vue 5 * @FilePath: /lls_program/src/components/RankingCard.vue
6 * @Description: 排行榜卡片组件 6 * @Description: 排行榜卡片组件
7 --> 7 -->
...@@ -46,6 +46,20 @@ ...@@ -46,6 +46,20 @@
46 <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> 46 <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>
47 </view> 47 </view>
48 48
49 + <view class="relative mb-2 text-white">
50 + <view class="flex items-center justify-center">
51 + <text class="mr-1" style="font-size: 28rpx;">总数: </text>
52 + <NumberRoll
53 + ref="numberRollRef"
54 + :target-value="currentTotalFamilySum"
55 + :digit-count="currentTotalFamilySum.length"
56 + :duration="1500"
57 + :digit-delay="150"
58 + />
59 + <text class="mr-1" style="font-size: 28rpx;">个家庭</text>
60 + </view>
61 + </view>
62 +
49 <!-- 助力榜空状态提示 --> 63 <!-- 助力榜空状态提示 -->
50 <view v-if="activeTab === 'support' && (!supportData || !supportData.families || supportData.families.length === 0)" class="support-empty-state"> 64 <view v-if="activeTab === 'support' && (!supportData || !supportData.families || supportData.families.length === 0)" class="support-empty-state">
51 <view class="empty-image"> 65 <view class="empty-image">
...@@ -174,9 +188,10 @@ ...@@ -174,9 +188,10 @@
174 </template> 188 </template>
175 189
176 <script setup> 190 <script setup>
177 -import { ref, computed, onMounted } from 'vue' 191 +import { ref, computed, onMounted, watch, nextTick } from 'vue'
178 import Taro from '@tarojs/taro' 192 import Taro from '@tarojs/taro'
179 import { IconFont } from '@nutui/icons-vue-taro'; 193 import { IconFont } from '@nutui/icons-vue-taro';
194 +import NumberRoll from './NumberRoll.vue'
180 // 默认头像 195 // 默认头像
181 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' 196 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'
182 // 助力榜图片 197 // 助力榜图片
...@@ -228,7 +243,11 @@ const loading = ref(false) ...@@ -228,7 +243,11 @@ const loading = ref(false)
228 // 排行榜日期 243 // 排行榜日期
229 const currentDate = ref('') 244 const currentDate = ref('')
230 245
246 +// 当前总家庭数
247 +const currentTotalFamilySum = ref(0);
231 248
249 +// 数字滚动组件引用
250 +const numberRollRef = ref(null)
232 251
233 /** 252 /**
234 * 切换tab 253 * 切换tab
...@@ -281,6 +300,9 @@ const loadLeaderboardData = async (isInitialLoad = false) => { ...@@ -281,6 +300,9 @@ const loadLeaderboardData = async (isInitialLoad = false) => {
281 } 300 }
282 // 设置当前日期 301 // 设置当前日期
283 currentDate.value = response.data.yesterday 302 currentDate.value = response.data.yesterday
303 + // TODO: 设置总家庭数
304 + const newTotalFamilySum = 2318;
305 + currentTotalFamilySum.value = newTotalFamilySum;
284 } 306 }
285 } catch (error) { 307 } catch (error) {
286 console.error('获取排行榜数据失败:', error) 308 console.error('获取排行榜数据失败:', error)
...@@ -465,7 +487,17 @@ onMounted(async () => { ...@@ -465,7 +487,17 @@ onMounted(async () => {
465 487
466 // 暴露刷新方法给父组件 488 // 暴露刷新方法给父组件
467 defineExpose({ 489 defineExpose({
468 - refreshData 490 + refreshData,
491 + startNumberRoll: () => {
492 + if (numberRollRef.value) {
493 + numberRollRef.value.start()
494 + }
495 + },
496 + resetNumberRoll: () => {
497 + if (numberRollRef.value) {
498 + numberRollRef.value.reset()
499 + }
500 + }
469 }) 501 })
470 </script> 502 </script>
471 503
...@@ -582,7 +614,7 @@ defineExpose({ ...@@ -582,7 +614,7 @@ defineExpose({
582 color: rgba(255, 255, 255, 0.8); 614 color: rgba(255, 255, 255, 0.8);
583 text-align: center; 615 text-align: center;
584 margin-top: 40rpx; 616 margin-top: 40rpx;
585 - margin-bottom: 40rpx; 617 + margin-bottom: 10rpx;
586 } 618 }
587 } 619 }
588 620
......
1 <!-- 1 <!--
2 * @Date: 2025-08-27 17:43:45 2 * @Date: 2025-08-27 17:43:45
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-10-01 11:30:56 4 + * @LastEditTime: 2025-10-25 18:22:46
5 * @FilePath: /lls_program/src/pages/Dashboard/index.vue 5 * @FilePath: /lls_program/src/pages/Dashboard/index.vue
6 * @Description: 首页 6 * @Description: 首页
7 --> 7 -->
...@@ -149,7 +149,9 @@ ...@@ -149,7 +149,9 @@
149 </WeRunAuth> 149 </WeRunAuth>
150 150
151 <!-- 排行榜卡片 --> 151 <!-- 排行榜卡片 -->
152 + <view id="ranking-card-container" ref="rankingCardContainer">
152 <RankingCard ref="rankingCardRef" :onViewMore="openFamilyRank" /> 153 <RankingCard ref="rankingCardRef" :onViewMore="openFamilyRank" />
154 + </view>
153 155
154 <!-- Family album --> 156 <!-- Family album -->
155 <FamilyAlbum 157 <FamilyAlbum
...@@ -172,7 +174,7 @@ ...@@ -172,7 +174,7 @@
172 <script setup> 174 <script setup>
173 import "./index.less"; 175 import "./index.less";
174 import { ref, computed, onMounted, onUnmounted } from 'vue'; 176 import { ref, computed, onMounted, onUnmounted } from 'vue';
175 -import Taro, { useDidShow, useReady, useLoad } from '@tarojs/taro'; 177 +import Taro, { useDidShow, useReady, useLoad, usePageScroll } from '@tarojs/taro';
176 import { handleSharePageAuth, addShareFlag } from '@/utils/authRedirect'; 178 import { handleSharePageAuth, addShareFlag } from '@/utils/authRedirect';
177 import { Setting, Photograph, IconFont } from '@nutui/icons-vue-taro'; 179 import { Setting, Photograph, IconFont } from '@nutui/icons-vue-taro';
178 import BottomNav from '@/components/BottomNav.vue'; 180 import BottomNav from '@/components/BottomNav.vue';
...@@ -195,6 +197,7 @@ const isWeRunAuthorized = ref(false); ...@@ -195,6 +197,7 @@ const isWeRunAuthorized = ref(false);
195 const pointsCollectorRef = ref(null) 197 const pointsCollectorRef = ref(null)
196 const weRunAuthRef = ref(null) 198 const weRunAuthRef = ref(null)
197 const rankingCardRef = ref(null) 199 const rankingCardRef = ref(null)
200 +const rankingCardContainer = ref(null)
198 const showTotalPointsOnly = ref(false) 201 const showTotalPointsOnly = ref(false)
199 const finalTotalPoints = ref(0) 202 const finalTotalPoints = ref(0)
200 const pendingPoints = ref([]) // 待收集的积分数据 203 const pendingPoints = ref([]) // 待收集的积分数据
...@@ -302,19 +305,33 @@ const handleSyncFailed = (data) => { ...@@ -302,19 +305,33 @@ const handleSyncFailed = (data) => {
302 console.log('微信步数同步失败:', data) 305 console.log('微信步数同步失败:', data)
303 } 306 }
304 307
308 +// 滚动监听相关
309 +const hasTriggeredNumberRoll = ref(false)
310 +
305 // 监听全局的微信步数同步成功事件(用于处理401重试后的成功情况) 311 // 监听全局的微信步数同步成功事件(用于处理401重试后的成功情况)
306 onMounted(() => { 312 onMounted(() => {
307 if (Taro.eventCenter) { 313 if (Taro.eventCenter) {
308 Taro.eventCenter.on('wx-steps-sync-success', handleStepsSynced); 314 Taro.eventCenter.on('wx-steps-sync-success', handleStepsSynced);
309 } 315 }
316 +
317 + // 设置滚动监听(实际通过 usePageScroll 实现)
318 + setupScrollListener()
310 }); 319 });
311 320
312 onUnmounted(() => { 321 onUnmounted(() => {
313 if (Taro.eventCenter) { 322 if (Taro.eventCenter) {
314 Taro.eventCenter.off('wx-steps-sync-success', handleStepsSynced); 323 Taro.eventCenter.off('wx-steps-sync-success', handleStepsSynced);
315 } 324 }
325 +
326 + // 清理滚动监听
327 + cleanupScrollListener()
316 }); 328 });
317 329
330 +// 使用 usePageScroll 监听页面滚动
331 +usePageScroll((res) => {
332 + handlePageScroll(res.scrollTop)
333 +})
334 +
318 /** 335 /**
319 * 计算总步数(包含用户步数和家庭成员步数) 336 * 计算总步数(包含用户步数和家庭成员步数)
320 * @returns {string} 格式化后的总步数 337 * @returns {string} 格式化后的总步数
...@@ -588,4 +605,82 @@ const isTabletDevice = computed(() => { ...@@ -588,4 +605,82 @@ const isTabletDevice = computed(() => {
588 // 普通手机设备比例通常在 0.4-0.6 之间 605 // 普通手机设备比例通常在 0.4-0.6 之间
589 return screenRatio > 0.65; 606 return screenRatio > 0.65;
590 }); 607 });
608 +
609 +/**
610 + * 设置滚动监听
611 + */
612 +const setupScrollListener = () => {
613 + // 使用 usePageScroll 监听页面滚动
614 + // 注意:usePageScroll 需要在组件顶层调用
615 +}
616 +
617 +/**
618 + * 清理滚动监听
619 + */
620 +const cleanupScrollListener = () => {
621 + // usePageScroll 会自动清理,无需手动清理
622 +}
623 +
624 +/**
625 + * 处理页面滚动事件
626 + * @param {number} scrollTop - 滚动距离
627 + */
628 +const handlePageScroll = (scrollTop) => {
629 + // 如果已经触发过数字滚动,则不再处理
630 + if (hasTriggeredNumberRoll.value) {
631 + return
632 + }
633 +
634 + // console.log('页面滚动:', scrollTop)
635 +
636 + // 检查 RankingCard 组件是否进入视窗
637 + if (rankingCardContainer.value) {
638 + Taro.createSelectorQuery()
639 + .select('#ranking-card-container')
640 + .boundingClientRect((rect) => {
641 + if (rect) {
642 + // console.log('RankingCard 位置信息:', rect)
643 + // 获取系统信息
644 + Taro.getSystemInfo({
645 + success: (res) => {
646 + const windowHeight = res.windowHeight
647 + // 当组件顶部进入视窗下方80%位置时触发
648 + const triggerPoint = windowHeight * 0.8
649 +
650 + // console.log('窗口高度:', windowHeight, '触发点:', triggerPoint, 'rect.top:', rect.top)
651 +
652 + if (rect.top <= triggerPoint && rect.bottom >= 0) {
653 + // console.log('触发数字滚动动画!')
654 + // 触发数字滚动动画
655 + triggerNumberRoll()
656 + }
657 + }
658 + })
659 + } else {
660 + console.log('未找到 RankingCard 容器元素')
661 + }
662 + })
663 + .exec()
664 + } else {
665 + console.log('rankingCardContainer.value 为空')
666 + }
667 +}
668 +
669 +/**
670 + * 触发数字滚动动画
671 + */
672 +const triggerNumberRoll = () => {
673 + if (hasTriggeredNumberRoll.value) {
674 + return
675 + }
676 +
677 + hasTriggeredNumberRoll.value = true
678 +
679 + // 延迟一点时间再触发,让用户看到滚动效果
680 + setTimeout(() => {
681 + if (rankingCardRef.value && rankingCardRef.value.startNumberRoll) {
682 + rankingCardRef.value.startNumberRoll()
683 + }
684 + }, 300)
685 +}
591 </script> 686 </script>
......