hookehuyr

feat: 添加积分收集组件并更新项目文档

添加 PointsCollector 积分收集组件,包含动画效果和一键收集功能
更新项目文档,补充项目描述和运行说明
1 # 项目规则 1 # 项目规则
2 -这是一个小程序项目,基于 Taro4 框架,使用 vue 写法编写,组件库使用 NutUI4,图标库使用 @nutui/icons-vue-taro。 2 +# 项目名称
3 + 老来赛 Taro小程序项目, 是一个和老人一起使用的记录步数的小程序, 可以分享海报, 并且可以通过步数兑换商品.
4 +
5 +
6 +## 代码参考
7 +1. 参考代码是react编写, 目录在.resource文件夹里面
3 8
4 ## 项目依赖 9 ## 项目依赖
5 1. 项目基于 Taro4, 文档参考: https://docs.taro.zone/docs/. 10 1. 项目基于 Taro4, 文档参考: https://docs.taro.zone/docs/.
...@@ -7,6 +12,11 @@ ...@@ -7,6 +12,11 @@
7 3. 项目中使用 NutUI4 组件库. 12 3. 项目中使用 NutUI4 组件库.
8 4. 项目中使用 @nutui/icons-vue-taro 图标库, 如果遇到 lucide-react 引用, 替换成相应的icons-vue-taro图标. 13 4. 项目中使用 @nutui/icons-vue-taro 图标库, 如果遇到 lucide-react 引用, 替换成相应的icons-vue-taro图标.
9 5. CSS部分使用 Tailwindcss. 14 5. CSS部分使用 Tailwindcss.
15 +6. 项目使用css尺寸 rpx
16 +7. 项目技术参考在resource/doc文件夹里面, 有关于API使用和组件使用的说明
17 +
18 +## 项目运行测试
19 +╰─ npm run dev:weapp 只测试微信小程序
10 20
11 ## 功能清单 21 ## 功能清单
12 | 序号 | 模块 | 功能 | | 22 | 序号 | 模块 | 功能 | |
......
...@@ -11,6 +11,7 @@ declare module 'vue' { ...@@ -11,6 +11,7 @@ declare module 'vue' {
11 NutButton: typeof import('@nutui/nutui-taro')['Button'] 11 NutButton: typeof import('@nutui/nutui-taro')['Button']
12 NutToast: typeof import('@nutui/nutui-taro')['Toast'] 12 NutToast: typeof import('@nutui/nutui-taro')['Toast']
13 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default'] 13 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
14 + PointsCollector: typeof import('./src/components/PointsCollector.vue')['default']
14 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] 15 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
15 RouterLink: typeof import('vue-router')['RouterLink'] 16 RouterLink: typeof import('vue-router')['RouterLink']
16 RouterView: typeof import('vue-router')['RouterView'] 17 RouterView: typeof import('vue-router')['RouterView']
......
1 +<template>
2 + <view class="points-collector">
3 + <!-- 中心圆形显示总积分 -->
4 + <view class="center-circle">
5 + <view class="total-points">
6 + <text class="points-number">{{ animatedTotalPoints }}</text>
7 + <text class="points-label">总积分</text>
8 + </view>
9 + </view>
10 +
11 + <!-- 周围漂浮的小圆圈 -->
12 + <view
13 + v-for="(item, index) in floatingItems"
14 + :key="item.id"
15 + class="floating-item"
16 + :class="{ 'collecting': item.collecting, 'stacked': item.stacked }"
17 + :style="getItemStyle(item, index)"
18 + @tap="collectItem(item, index)"
19 + >
20 + <view class="item-content">
21 + <text class="item-value">{{ item.value }}</text>
22 + <text class="item-type">{{ item.type === 'steps' ? '步' : '分' }}</text>
23 + </view>
24 + <!-- 堆叠数量显示 -->
25 + <view v-if="item.stackCount > 1" class="stack-count">
26 + <text>{{ item.stackCount }}</text>
27 + </view>
28 + </view>
29 +
30 + <!-- 一键收取按钮 -->
31 + <view
32 + v-if="floatingItems.length > 3"
33 + class="collect-all-btn"
34 + @tap="collectAll"
35 + >
36 + <text>一键收取</text>
37 + </view>
38 +
39 + <!-- 收集特效 -->
40 + <view
41 + v-for="effect in collectEffects"
42 + :key="effect.id"
43 + class="collect-effect"
44 + :style="effect.style"
45 + >
46 + <text>+{{ effect.value }}</text>
47 + </view>
48 + </view>
49 +</template>
50 +
51 +<script setup>
52 +import { ref, computed, onMounted, nextTick } from 'vue'
53 +import Taro from '@tarojs/taro'
54 +
55 +// 响应式数据
56 +const totalPoints = ref(1250) // 总积分
57 +const animatedTotalPoints = ref(1250) // 动画中的总积分
58 +const floatingItems = ref([]) // 漂浮的积分项
59 +const collectEffects = ref([]) // 收集特效数组
60 +const isCollecting = ref(false) // 是否正在收集
61 +
62 +/**
63 + * 生成模拟数据
64 + */
65 +const generateMockData = () => {
66 + const mockItems = [
67 + { id: 1, type: 'steps', value: 500, collected: false },
68 + { id: 2, type: 'points', value: 50, collected: false },
69 + { id: 3, type: 'steps', value: 800, collected: false },
70 + { id: 4, type: 'points', value: 30, collected: false },
71 + { id: 5, type: 'steps', value: 1200, collected: false },
72 + { id: 6, type: 'points', value: 80, collected: false },
73 + { id: 7, type: 'points', value: 25, collected: false },
74 + { id: 8, type: 'steps', value: 600, collected: false },
75 + { id: 9, type: 'points', value: 100, collected: false },
76 + { id: 10, type: 'steps', value: 1000, collected: false },
77 + { id: 11, type: 'points', value: 100, collected: false },
78 + { id: 12, type: 'steps', value: 1000, collected: false },
79 + { id: 13, type: 'points', value: 100, collected: false },
80 + { id: 14, type: 'steps', value: 1000, collected: false },
81 + { id: 15, type: 'points', value: 100, collected: false },
82 + { id: 16, type: 'steps', value: 1000, collected: false },
83 + { id: 17, type: 'points', value: 100, collected: false },
84 + { id: 18, type: 'steps', value: 1000, collected: false },
85 + { id: 19, type: 'points', value: 100, collected: false },
86 + { id: 20, type: 'steps', value: 1000, collected: false },
87 + ]
88 +
89 + // 处理堆叠逻辑
90 + const processedItems = []
91 + const itemGroups = {}
92 +
93 + mockItems.forEach(item => {
94 + const key = `${item.type}_${item.value}`
95 + if (itemGroups[key]) {
96 + itemGroups[key].stackCount++
97 + } else {
98 + itemGroups[key] = { ...item, stackCount: 1, stacked: false }
99 + processedItems.push(itemGroups[key])
100 + }
101 + })
102 +
103 + // 标记堆叠项
104 + processedItems.forEach(item => {
105 + if (item.stackCount > 1) {
106 + item.stacked = true
107 + }
108 + })
109 +
110 + return processedItems
111 +}
112 +
113 +/**
114 + * 获取项目样式(位置和大小)
115 + */
116 +const getItemStyle = (item, index) => {
117 + const centerX = 375 // 屏幕中心X
118 + const centerY = 400 // 屏幕中心Y
119 + const radius = 200 // 分布半径
120 +
121 + // 根据索引计算角度
122 + const angle = (index * 45) + (Math.random() * 30 - 15) // 添加随机偏移
123 + const radian = (angle * Math.PI) / 180
124 +
125 + // 计算位置
126 + const x = centerX + Math.cos(radian) * (radius + Math.random() * 50)
127 + const y = centerY + Math.sin(radian) * (radius + Math.random() * 50)
128 +
129 + // 根据数值大小计算圆圈大小
130 + const baseSize = 80
131 + const maxValue = Math.max(...floatingItems.value.map(i => i.value))
132 + const sizeRatio = item.value / maxValue
133 + const size = baseSize + (sizeRatio * 40)
134 +
135 + return {
136 + position: 'absolute',
137 + left: `${x - size/2}rpx`,
138 + top: `${y - size/2}rpx`,
139 + width: `${size}rpx`,
140 + height: `${size}rpx`,
141 + transform: item.collecting ? 'scale(0)' : 'scale(1)',
142 + transition: 'all 0.5s ease-in-out'
143 + }
144 +}
145 +
146 +/**
147 + * 收集单个项目
148 + */
149 +const collectItem = async (item, index) => {
150 + if (item.collecting || isCollecting.value) return
151 +
152 + item.collecting = true
153 +
154 + // 创建收集特效
155 + const effect = {
156 + id: Date.now(),
157 + value: item.type === 'steps' ? Math.floor(item.value / 10) : item.value,
158 + style: {
159 + position: 'absolute',
160 + left: '50%',
161 + top: '50%',
162 + transform: 'translate(-50%, -50%)',
163 + opacity: 1,
164 + transition: 'all 1s ease-out'
165 + }
166 + }
167 +
168 + collectEffects.value.push(effect)
169 +
170 + // 动画效果
171 + await nextTick()
172 + effect.style.transform = 'translate(-50%, -150%)'
173 + effect.style.opacity = 0
174 +
175 + // 更新总积分(步数转换为积分,比例1:10)
176 + const pointsToAdd = item.type === 'steps' ? Math.floor(item.value / 10) : item.value
177 + const stackMultiplier = item.stackCount || 1
178 + const totalToAdd = pointsToAdd * stackMultiplier
179 +
180 + // 数字滚动效果
181 + animateNumber(totalPoints.value, totalPoints.value + totalToAdd)
182 + totalPoints.value += totalToAdd
183 +
184 + // 延迟移除项目和特效
185 + setTimeout(() => {
186 + floatingItems.value.splice(index, 1)
187 + collectEffects.value = collectEffects.value.filter(e => e.id !== effect.id)
188 + }, 1000)
189 +}
190 +
191 +/**
192 + * 一键收取所有积分
193 + */
194 +const collectAll = async () => {
195 + if (isCollecting.value) return
196 +
197 + isCollecting.value = true
198 +
199 + // 计算总积分
200 + let totalToAdd = 0
201 + floatingItems.value.forEach(item => {
202 + const pointsToAdd = item.type === 'steps' ? Math.floor(item.value / 10) : item.value
203 + const stackMultiplier = item.stackCount || 1
204 + totalToAdd += pointsToAdd * stackMultiplier
205 + })
206 +
207 + // 批量收集动画
208 + floatingItems.value.forEach((item, index) => {
209 + setTimeout(() => {
210 + item.collecting = true
211 + }, index * 100)
212 + })
213 +
214 + // 创建总收集特效
215 + const effect = {
216 + id: Date.now(),
217 + value: totalToAdd,
218 + style: {
219 + position: 'absolute',
220 + left: '50%',
221 + top: '50%',
222 + transform: 'translate(-50%, -50%)',
223 + opacity: 1,
224 + fontSize: '48rpx',
225 + color: '#ff6b35',
226 + fontWeight: 'bold',
227 + transition: 'all 1.5s ease-out'
228 + }
229 + }
230 +
231 + collectEffects.value.push(effect)
232 +
233 + await nextTick()
234 + effect.style.transform = 'translate(-50%, -200%)'
235 + effect.style.opacity = 0
236 +
237 + // 数字滚动效果
238 + animateNumber(totalPoints.value, totalPoints.value + totalToAdd)
239 + totalPoints.value += totalToAdd
240 +
241 + // 清空所有项目
242 + setTimeout(() => {
243 + floatingItems.value = []
244 + collectEffects.value = []
245 + isCollecting.value = false
246 +
247 + // 重新生成数据(模拟新的积分)
248 + setTimeout(() => {
249 + floatingItems.value = generateMockData()
250 + }, 2000)
251 + }, 1500)
252 +}
253 +
254 +/**
255 + * 数字滚动动画
256 + */
257 +const animateNumber = (start, end) => {
258 + const duration = 1000
259 + const startTime = Date.now()
260 + const difference = end - start
261 +
262 + const animate = () => {
263 + const elapsed = Date.now() - startTime
264 + const progress = Math.min(elapsed / duration, 1)
265 +
266 + // 使用缓动函数
267 + const easeOut = 1 - Math.pow(1 - progress, 3)
268 + animatedTotalPoints.value = Math.floor(start + difference * easeOut)
269 +
270 + if (progress < 1) {
271 + requestAnimationFrame(animate)
272 + }
273 + }
274 +
275 + animate()
276 +}
277 +
278 +// 组件挂载时初始化数据
279 +onMounted(() => {
280 + floatingItems.value = generateMockData()
281 +})
282 +</script>
283 +
284 +<style lang="less">
285 +.points-collector {
286 + position: relative;
287 + width: 100vw;
288 + height: 100vh;
289 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
290 + overflow: hidden;
291 +}
292 +
293 +.center-circle {
294 + position: absolute;
295 + left: 50%;
296 + top: 50%;
297 + transform: translate(-50%, -50%);
298 + width: 200rpx;
299 + height: 200rpx;
300 + background: linear-gradient(135deg, #ff6b35, #f7931e);
301 + border-radius: 50%;
302 + display: flex;
303 + align-items: center;
304 + justify-content: center;
305 + box-shadow: 0 8rpx 32rpx rgba(255, 107, 53, 0.3);
306 + z-index: 10;
307 +}
308 +
309 +.total-points {
310 + text-align: center;
311 + color: white;
312 +}
313 +
314 +.points-number {
315 + display: block;
316 + font-size: 48rpx;
317 + font-weight: bold;
318 + line-height: 1;
319 +}
320 +
321 +.points-label {
322 + display: block;
323 + font-size: 24rpx;
324 + margin-top: 8rpx;
325 + opacity: 0.9;
326 +}
327 +
328 +.floating-item {
329 + position: absolute;
330 + background: rgba(255, 255, 255, 0.9);
331 + border-radius: 50%;
332 + display: flex;
333 + align-items: center;
334 + justify-content: center;
335 + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
336 + cursor: pointer;
337 + animation: float 3s ease-in-out infinite;
338 +
339 + &.stacked {
340 + border: 4rpx solid #ff6b35;
341 + }
342 +
343 + &.collecting {
344 + transform: scale(0) !important;
345 + }
346 +}
347 +
348 +.item-content {
349 + text-align: center;
350 + color: #333;
351 +}
352 +
353 +.item-value {
354 + display: block;
355 + font-size: 28rpx;
356 + font-weight: bold;
357 + line-height: 1;
358 +}
359 +
360 +.item-type {
361 + display: block;
362 + font-size: 20rpx;
363 + margin-top: 4rpx;
364 + opacity: 0.7;
365 +}
366 +
367 +.stack-count {
368 + position: absolute;
369 + top: -8rpx;
370 + right: -8rpx;
371 + width: 32rpx;
372 + height: 32rpx;
373 + background: #ff6b35;
374 + border-radius: 50%;
375 + display: flex;
376 + align-items: center;
377 + justify-content: center;
378 + color: white;
379 + font-size: 20rpx;
380 + font-weight: bold;
381 +}
382 +
383 +.collect-all-btn {
384 + position: absolute;
385 + bottom: 100rpx;
386 + left: 50%;
387 + transform: translateX(-50%);
388 + background: linear-gradient(135deg, #ff6b35, #f7931e);
389 + color: white;
390 + padding: 24rpx 48rpx;
391 + border-radius: 48rpx;
392 + font-size: 32rpx;
393 + font-weight: bold;
394 + box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
395 + z-index: 20;
396 +}
397 +
398 +.collect-effect {
399 + pointer-events: none;
400 + color: #ff6b35;
401 + font-size: 36rpx;
402 + font-weight: bold;
403 + z-index: 30;
404 +}
405 +
406 +@keyframes float {
407 + 0%, 100% {
408 + transform: translateY(0px) rotate(0deg);
409 + }
410 + 25% {
411 + transform: translateY(-10px) rotate(1deg);
412 + }
413 + 50% {
414 + transform: translateY(-5px) rotate(-1deg);
415 + }
416 + 75% {
417 + transform: translateY(-15px) rotate(0.5deg);
418 + }
419 +}
420 +
421 +// 响应式适配
422 +@media screen and (max-width: 750px) {
423 + .center-circle {
424 + width: 160rpx;
425 + height: 160rpx;
426 + }
427 +
428 + .points-number {
429 + font-size: 36rpx;
430 + }
431 +
432 + .points-label {
433 + font-size: 20rpx;
434 + }
435 +}
436 +</style>
1 <!-- 1 <!--
2 * @Date: 2025-06-28 10:33:00 2 * @Date: 2025-06-28 10:33:00
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-07-01 11:13:13 4 + * @LastEditTime: 2025-08-27 15:22:35
5 * @FilePath: /lls_program/src/pages/index/index.vue 5 * @FilePath: /lls_program/src/pages/index/index.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
8 <template> 8 <template>
9 <view class="index"> 9 <view class="index">
10 <nut-button type="primary" @click="onClick">按钮</nut-button> 10 <nut-button type="primary" @click="onClick">按钮</nut-button>
11 + <nut-button type="success" @click="showPointsCollector" style="margin-left: 20rpx;">积分收集</nut-button>
11 <nut-toast v-model:visible="show" msg="你成功了" /> 12 <nut-toast v-model:visible="show" msg="你成功了" />
12 - <View className="text-[#acc855] text-[100px]">Hello world!</View> 13 + <!-- <View className="text-[#acc855] text-[100px]">Hello world!</View> -->
14 +
15 + <!-- 积分收集组件 -->
16 + <PointsCollector v-if="showCollector" />
13 </view> 17 </view>
14 </template> 18 </template>
15 19
...@@ -18,13 +22,23 @@ import Taro from '@tarojs/taro' ...@@ -18,13 +22,23 @@ import Taro from '@tarojs/taro'
18 import '@tarojs/taro/html.css' 22 import '@tarojs/taro/html.css'
19 import { ref, onMounted } from 'vue' 23 import { ref, onMounted } from 'vue'
20 import { useDidShow, useReady } from '@tarojs/taro' 24 import { useDidShow, useReady } from '@tarojs/taro'
25 +import PointsCollector from '@/components/PointsCollector.vue'
21 import "./index.less"; 26 import "./index.less";
22 27
23 const show = ref(false) 28 const show = ref(false)
29 +const showCollector = ref(false)
30 +
24 const onClick = () => { 31 const onClick = () => {
25 show.value = true 32 show.value = true
26 } 33 }
27 34
35 +/**
36 + * 显示积分收集组件
37 + */
38 +const showPointsCollector = () => {
39 + showCollector.value = !showCollector.value
40 +}
41 +
28 // 生命周期钩子 42 // 生命周期钩子
29 useDidShow(() => { 43 useDidShow(() => {
30 console.warn('index onShow') 44 console.warn('index onShow')
......