hookehuyr

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

添加 PointsCollector 积分收集组件,包含动画效果和一键收集功能
更新项目文档,补充项目描述和运行说明
# 项目规则
这是一个小程序项目,基于 Taro4 框架,使用 vue 写法编写,组件库使用 NutUI4,图标库使用 @nutui/icons-vue-taro。
# 项目名称
老来赛 Taro小程序项目, 是一个和老人一起使用的记录步数的小程序, 可以分享海报, 并且可以通过步数兑换商品.
## 代码参考
1. 参考代码是react编写, 目录在.resource文件夹里面
## 项目依赖
1. 项目基于 Taro4, 文档参考: https://docs.taro.zone/docs/.
......@@ -7,6 +12,11 @@
3. 项目中使用 NutUI4 组件库.
4. 项目中使用 @nutui/icons-vue-taro 图标库, 如果遇到 lucide-react 引用, 替换成相应的icons-vue-taro图标.
5. CSS部分使用 Tailwindcss.
6. 项目使用css尺寸 rpx
7. 项目技术参考在resource/doc文件夹里面, 有关于API使用和组件使用的说明
## 项目运行测试
╰─ npm run dev:weapp 只测试微信小程序
## 功能清单
| 序号 | 模块 | 功能 | |
......
......@@ -11,6 +11,7 @@ declare module 'vue' {
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutToast: typeof import('@nutui/nutui-taro')['Toast']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PointsCollector: typeof import('./src/components/PointsCollector.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
......
<template>
<view class="points-collector">
<!-- 中心圆形显示总积分 -->
<view class="center-circle">
<view class="total-points">
<text class="points-number">{{ animatedTotalPoints }}</text>
<text class="points-label">总积分</text>
</view>
</view>
<!-- 周围漂浮的小圆圈 -->
<view
v-for="(item, index) in floatingItems"
:key="item.id"
class="floating-item"
:class="{ 'collecting': item.collecting, 'stacked': item.stacked }"
:style="getItemStyle(item, index)"
@tap="collectItem(item, index)"
>
<view class="item-content">
<text class="item-value">{{ item.value }}</text>
<text class="item-type">{{ item.type === 'steps' ? '步' : '分' }}</text>
</view>
<!-- 堆叠数量显示 -->
<view v-if="item.stackCount > 1" class="stack-count">
<text>{{ item.stackCount }}</text>
</view>
</view>
<!-- 一键收取按钮 -->
<view
v-if="floatingItems.length > 3"
class="collect-all-btn"
@tap="collectAll"
>
<text>一键收取</text>
</view>
<!-- 收集特效 -->
<view
v-for="effect in collectEffects"
:key="effect.id"
class="collect-effect"
:style="effect.style"
>
<text>+{{ effect.value }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import Taro from '@tarojs/taro'
// 响应式数据
const totalPoints = ref(1250) // 总积分
const animatedTotalPoints = ref(1250) // 动画中的总积分
const floatingItems = ref([]) // 漂浮的积分项
const collectEffects = ref([]) // 收集特效数组
const isCollecting = ref(false) // 是否正在收集
/**
* 生成模拟数据
*/
const generateMockData = () => {
const mockItems = [
{ id: 1, type: 'steps', value: 500, collected: false },
{ id: 2, type: 'points', value: 50, collected: false },
{ id: 3, type: 'steps', value: 800, collected: false },
{ id: 4, type: 'points', value: 30, collected: false },
{ id: 5, type: 'steps', value: 1200, collected: false },
{ id: 6, type: 'points', value: 80, collected: false },
{ id: 7, type: 'points', value: 25, collected: false },
{ id: 8, type: 'steps', value: 600, collected: false },
{ id: 9, type: 'points', value: 100, collected: false },
{ id: 10, type: 'steps', value: 1000, collected: false },
{ id: 11, type: 'points', value: 100, collected: false },
{ id: 12, type: 'steps', value: 1000, collected: false },
{ id: 13, type: 'points', value: 100, collected: false },
{ id: 14, type: 'steps', value: 1000, collected: false },
{ id: 15, type: 'points', value: 100, collected: false },
{ id: 16, type: 'steps', value: 1000, collected: false },
{ id: 17, type: 'points', value: 100, collected: false },
{ id: 18, type: 'steps', value: 1000, collected: false },
{ id: 19, type: 'points', value: 100, collected: false },
{ id: 20, type: 'steps', value: 1000, collected: false },
]
// 处理堆叠逻辑
const processedItems = []
const itemGroups = {}
mockItems.forEach(item => {
const key = `${item.type}_${item.value}`
if (itemGroups[key]) {
itemGroups[key].stackCount++
} else {
itemGroups[key] = { ...item, stackCount: 1, stacked: false }
processedItems.push(itemGroups[key])
}
})
// 标记堆叠项
processedItems.forEach(item => {
if (item.stackCount > 1) {
item.stacked = true
}
})
return processedItems
}
/**
* 获取项目样式(位置和大小)
*/
const getItemStyle = (item, index) => {
const centerX = 375 // 屏幕中心X
const centerY = 400 // 屏幕中心Y
const radius = 200 // 分布半径
// 根据索引计算角度
const angle = (index * 45) + (Math.random() * 30 - 15) // 添加随机偏移
const radian = (angle * Math.PI) / 180
// 计算位置
const x = centerX + Math.cos(radian) * (radius + Math.random() * 50)
const y = centerY + Math.sin(radian) * (radius + Math.random() * 50)
// 根据数值大小计算圆圈大小
const baseSize = 80
const maxValue = Math.max(...floatingItems.value.map(i => i.value))
const sizeRatio = item.value / maxValue
const size = baseSize + (sizeRatio * 40)
return {
position: 'absolute',
left: `${x - size/2}rpx`,
top: `${y - size/2}rpx`,
width: `${size}rpx`,
height: `${size}rpx`,
transform: item.collecting ? 'scale(0)' : 'scale(1)',
transition: 'all 0.5s ease-in-out'
}
}
/**
* 收集单个项目
*/
const collectItem = async (item, index) => {
if (item.collecting || isCollecting.value) return
item.collecting = true
// 创建收集特效
const effect = {
id: Date.now(),
value: item.type === 'steps' ? Math.floor(item.value / 10) : item.value,
style: {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
opacity: 1,
transition: 'all 1s ease-out'
}
}
collectEffects.value.push(effect)
// 动画效果
await nextTick()
effect.style.transform = 'translate(-50%, -150%)'
effect.style.opacity = 0
// 更新总积分(步数转换为积分,比例1:10)
const pointsToAdd = item.type === 'steps' ? Math.floor(item.value / 10) : item.value
const stackMultiplier = item.stackCount || 1
const totalToAdd = pointsToAdd * stackMultiplier
// 数字滚动效果
animateNumber(totalPoints.value, totalPoints.value + totalToAdd)
totalPoints.value += totalToAdd
// 延迟移除项目和特效
setTimeout(() => {
floatingItems.value.splice(index, 1)
collectEffects.value = collectEffects.value.filter(e => e.id !== effect.id)
}, 1000)
}
/**
* 一键收取所有积分
*/
const collectAll = async () => {
if (isCollecting.value) return
isCollecting.value = true
// 计算总积分
let totalToAdd = 0
floatingItems.value.forEach(item => {
const pointsToAdd = item.type === 'steps' ? Math.floor(item.value / 10) : item.value
const stackMultiplier = item.stackCount || 1
totalToAdd += pointsToAdd * stackMultiplier
})
// 批量收集动画
floatingItems.value.forEach((item, index) => {
setTimeout(() => {
item.collecting = true
}, index * 100)
})
// 创建总收集特效
const effect = {
id: Date.now(),
value: totalToAdd,
style: {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
opacity: 1,
fontSize: '48rpx',
color: '#ff6b35',
fontWeight: 'bold',
transition: 'all 1.5s ease-out'
}
}
collectEffects.value.push(effect)
await nextTick()
effect.style.transform = 'translate(-50%, -200%)'
effect.style.opacity = 0
// 数字滚动效果
animateNumber(totalPoints.value, totalPoints.value + totalToAdd)
totalPoints.value += totalToAdd
// 清空所有项目
setTimeout(() => {
floatingItems.value = []
collectEffects.value = []
isCollecting.value = false
// 重新生成数据(模拟新的积分)
setTimeout(() => {
floatingItems.value = generateMockData()
}, 2000)
}, 1500)
}
/**
* 数字滚动动画
*/
const animateNumber = (start, end) => {
const duration = 1000
const startTime = Date.now()
const difference = end - start
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// 使用缓动函数
const easeOut = 1 - Math.pow(1 - progress, 3)
animatedTotalPoints.value = Math.floor(start + difference * easeOut)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
animate()
}
// 组件挂载时初始化数据
onMounted(() => {
floatingItems.value = generateMockData()
})
</script>
<style lang="less">
.points-collector {
position: relative;
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
}
.center-circle {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 200rpx;
height: 200rpx;
background: linear-gradient(135deg, #ff6b35, #f7931e);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(255, 107, 53, 0.3);
z-index: 10;
}
.total-points {
text-align: center;
color: white;
}
.points-number {
display: block;
font-size: 48rpx;
font-weight: bold;
line-height: 1;
}
.points-label {
display: block;
font-size: 24rpx;
margin-top: 8rpx;
opacity: 0.9;
}
.floating-item {
position: absolute;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
cursor: pointer;
animation: float 3s ease-in-out infinite;
&.stacked {
border: 4rpx solid #ff6b35;
}
&.collecting {
transform: scale(0) !important;
}
}
.item-content {
text-align: center;
color: #333;
}
.item-value {
display: block;
font-size: 28rpx;
font-weight: bold;
line-height: 1;
}
.item-type {
display: block;
font-size: 20rpx;
margin-top: 4rpx;
opacity: 0.7;
}
.stack-count {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 32rpx;
height: 32rpx;
background: #ff6b35;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20rpx;
font-weight: bold;
}
.collect-all-btn {
position: absolute;
bottom: 100rpx;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #ff6b35, #f7931e);
color: white;
padding: 24rpx 48rpx;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
z-index: 20;
}
.collect-effect {
pointer-events: none;
color: #ff6b35;
font-size: 36rpx;
font-weight: bold;
z-index: 30;
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
}
25% {
transform: translateY(-10px) rotate(1deg);
}
50% {
transform: translateY(-5px) rotate(-1deg);
}
75% {
transform: translateY(-15px) rotate(0.5deg);
}
}
// 响应式适配
@media screen and (max-width: 750px) {
.center-circle {
width: 160rpx;
height: 160rpx;
}
.points-number {
font-size: 36rpx;
}
.points-label {
font-size: 20rpx;
}
}
</style>
<!--
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-01 11:13:13
* @LastEditTime: 2025-08-27 15:22:35
* @FilePath: /lls_program/src/pages/index/index.vue
* @Description: 文件描述
-->
<template>
<view class="index">
<nut-button type="primary" @click="onClick">按钮</nut-button>
<nut-button type="success" @click="showPointsCollector" style="margin-left: 20rpx;">积分收集</nut-button>
<nut-toast v-model:visible="show" msg="你成功了" />
<View className="text-[#acc855] text-[100px]">Hello world!</View>
<!-- <View className="text-[#acc855] text-[100px]">Hello world!</View> -->
<!-- 积分收集组件 -->
<PointsCollector v-if="showCollector" />
</view>
</template>
......@@ -18,13 +22,23 @@ import Taro from '@tarojs/taro'
import '@tarojs/taro/html.css'
import { ref, onMounted } from 'vue'
import { useDidShow, useReady } from '@tarojs/taro'
import PointsCollector from '@/components/PointsCollector.vue'
import "./index.less";
const show = ref(false)
const showCollector = ref(false)
const onClick = () => {
show.value = true
}
/**
* 显示积分收集组件
*/
const showPointsCollector = () => {
showCollector.value = !showCollector.value
}
// 生命周期钩子
useDidShow(() => {
console.warn('index onShow')
......