hookehuyr

feat(PointsCollector): 重构积分收集组件并添加一键收取功能

- 重构积分收集组件布局和样式
- 实现一键收取功能并优化收集动画
- 移除冗余代码并提升性能
- 添加组件高度可配置属性
<template>
<view class="points-collector">
<view class="points-collector" :style="{ height: height }">
<!-- 中心圆形显示总积分 -->
<view class="center-circle">
<view class="total-points">
<text class="points-number">{{ animatedTotalPoints }}</text>
<text class="points-label">总积分</text>
<text class="points-number">{{ animatedTotalPoints }}</text>
<text class="points-label">去兑换</text>
</view>
</view>
<!-- 周围漂浮的小圆圈 -->
<view
v-for="(item, index) in floatingItems"
v-for="(item) in floatingItems"
:key="item.id"
class="floating-item"
:class="{ 'collecting': item.collecting, 'stacked': item.stacked }"
:style="getItemStyle(item, index)"
@tap="collectItem(item, index)"
:style="getItemStyle(item)"
@tap="collectItem(item)"
>
<view class="item-content">
<text class="item-value">{{ item.value }}</text>
<text class="item-type">{{ item.type === 'steps' ? '步' : '分' }}</text>
<view v-if="item.type === 'steps'" class="item-content">
<text class="item-value">{{ item.steps }}步</text>
<text class="item-type">{{ item.value }}分</text>
</view>
<!-- 堆叠数量显示 -->
<view v-if="item.stackCount > 1" class="stack-count">
<text>{{ item.stackCount }}</text>
<view v-else class="item-content">
<text class="item-value">奖励</text>
<text class="item-type">{{ item.value }}分</text>
</view>
</view>
<!-- 一键收取按钮 -->
<view
v-if="floatingItems.length > 3"
<!-- <view
v-if="floatingItems.length > 0"
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> -->
</view>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, onMounted, nextTick, defineProps, defineExpose } from 'vue'
import Taro from '@tarojs/taro'
// 定义props
const props = defineProps({
height: {
type: String,
default: '30vh'
}
})
// 响应式数据
const totalPoints = ref(1250) // 总积分
const animatedTotalPoints = ref(1250) // 动画中的总积分
const totalPoints = ref(12500) // 总积分
const animatedTotalPoints = ref(12500) // 动画中的总积分
const floatingItems = ref([]) // 漂浮的积分项
const collectEffects = ref([]) // 收集特效数组
const isCollecting = ref(false) // 是否正在收集
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])
{ id: 1, type: 'steps', value: 5000, steps: 5000 },
{ id: 2, type: 'points', value: 500 },
{ id: 3, type: 'steps', value: 8000, steps: 5000 },
{ id: 4, type: 'points', value: 30 },
{ id: 5, type: 'steps', value: 12000, steps: 12000 },
{ id: 6, type: 'points', value: 800 },
{ id: 7, type: 'points', value: 250 },
{ id: 8, type: 'steps', value: 6000, steps: 6000 },
{ id: 9, type: 'points', value: 1000 },
{ id: 10, type: 'steps', value: 10000, steps: 10000 },
].map(item => ({ ...item, collecting: false })); // 初始化collecting状态
const maxValue = Math.max(...mockItems.map(i => i.value));
const baseSize = 80;
const { windowWidth, windowHeight } = Taro.getWindowInfo();
const positionedItems = [];
// 为每个项目分配一个不重叠的随机位置
return mockItems.map(item => {
let x, y, hasCollision;
const maxAttempts = 100; // 限制尝试次数以避免无限循环
let attempts = 0;
const centerNoFlyZone = 25; // 中心圆形25%的禁飞区半径
// 计算项目大小和半径
const sizeRatio = item.value / maxValue;
const size = baseSize + (sizeRatio * 40); // rpx
const radiusXPercent = (size / 750) * 100 / 2 * (windowWidth / windowHeight) ; // 归一化为高度的百分比
// 定义安全区域
const minX = radiusXPercent;
const maxX = 100 - radiusXPercent;
const minY = radiusXPercent + 5;
const maxY = 100 - radiusXPercent - 15;
do {
attempts++;
if (attempts > maxAttempts) {
console.warn('无法为项目找到不重叠的位置:', item);
break; // 超过尝试次数,接受当前位置
}
// 在计算出的边界内生成位置
x = Math.random() * (maxX - minX) + minX;
y = Math.random() * (maxY - minY) + minY;
// 检查与中心禁飞区的距离
const dxCenter = x - 50;
const dyCenter = y - 50;
const distanceFromCenter = Math.sqrt(dxCenter * dxCenter + dyCenter * dyCenter);
if (distanceFromCenter < centerNoFlyZone) {
hasCollision = true;
continue;
}
})
// 标记堆叠项
processedItems.forEach(item => {
if (item.stackCount > 1) {
item.stacked = true
// 检查与其他项目的碰撞
hasCollision = false;
for (const pItem of positionedItems) {
const dx = x - pItem.x;
const dy = y - pItem.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const combinedRadius = radiusXPercent + pItem.radiusXPercent;
if (distance < combinedRadius) {
hasCollision = true;
break;
}
}
})
} while (hasCollision);
return processedItems
positionedItems.push({ ...item, x, y, radiusXPercent });
return { ...item, x, y };
});
}
/**
* 获取项目样式(位置和大小
* 获取项目样式(位置、大小和动画
*/
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 {
const getItemStyle = (item) => {
const baseSize = 80;
const maxValue = Math.max(...(floatingItems.value.map(i => i.value).length > 0 ? floatingItems.value.map(i => i.value) : [1]));
const sizeRatio = item.value / maxValue;
const size = baseSize + (sizeRatio * 40);
const style = {
position: 'absolute',
left: `${x - size/2}rpx`,
top: `${y - size/2}rpx`,
left: `${item.x}%`,
top: `${item.y}%`,
width: `${size}rpx`,
height: `${size}rpx`,
transform: item.collecting ? 'scale(0)' : 'scale(1)',
transition: 'all 0.5s ease-in-out'
transform: 'translate(-50%, -50%) scale(1)',
transition: 'all 0.8s cubic-bezier(0.5, -0.5, 0.5, 1.5)',
zIndex: 15,
};
if (item.collecting) {
style.left = '50%';
style.top = '50%';
style.transform = 'translate(-50%, -50%) scale(0)';
style.opacity = 0;
style.zIndex = 20; // 飞向中心时置于顶层
}
return style;
}
/**
* 收集单个项目
*/
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)
const collectItem = (item) => {
if (item.collecting) return;
item.collecting = true;
// 动画效果
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 totalToAdd = item.type === 'steps' ? Math.floor(item.value / 10) : item.value;
animateNumber(totalPoints.value, totalPoints.value + totalToAdd);
totalPoints.value += totalToAdd;
floatingItems.value = floatingItems.value.filter(i => i.id !== item.id);
}, 800); // 动画时长
}
/**
* 一键收取所有积分
*/
const collectAll = async () => {
if (isCollecting.value) return
isCollecting.value = true
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
})
let totalToAdd = 0;
const itemsToCollect = [...floatingItems.value];
// 批量收集动画
floatingItems.value.forEach((item, index) => {
itemsToCollect.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
item.collecting = true;
}, index * 80); // 依次触发动画
totalToAdd += item.value;
});
// 数字滚动效果
animateNumber(totalPoints.value, totalPoints.value + totalToAdd)
totalPoints.value += totalToAdd
// 清空所有项目
const totalAnimationTime = itemsToCollect.length * 80 + 800;
setTimeout(() => {
floatingItems.value = []
collectEffects.value = []
isCollecting.value = false
animateNumber(totalPoints.value, totalPoints.value + totalToAdd);
totalPoints.value += totalToAdd;
// 重新生成数据(模拟新的积分)
setTimeout(() => {
floatingItems.value = generateMockData()
}, 2000)
}, 1500)
floatingItems.value = [];
isCollecting.value = false;
// 模拟2秒后重新生成
// setTimeout(() => {
// floatingItems.value = generateMockData();
// }, 2000);
}, totalAnimationTime);
}
/**
* 数字滚动动画
*/
const animateNumber = (start, end) => {
const duration = 1000
const startTime = Date.now()
const difference = end - start
const duration = 800;
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)
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)
}
requestAnimationFrame(animate);
}
animate()
};
animate();
}
// 暴露方法给父组件
defineExpose({
collectAll
})
// 组件挂载时初始化数据
onMounted(() => {
floatingItems.value = generateMockData()
floatingItems.value = generateMockData();
})
</script>
......@@ -286,7 +251,7 @@ onMounted(() => {
position: relative;
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
// background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
}
......@@ -297,12 +262,12 @@ onMounted(() => {
transform: translate(-50%, -50%);
width: 200rpx;
height: 200rpx;
background: linear-gradient(135deg, #ff6b35, #f7931e);
background: linear-gradient(135deg, #4A90E2, #4d96ea);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(255, 107, 53, 0.3);
box-shadow: 0 8rpx 32rpx rgba(53, 144, 255, 0.3);
z-index: 10;
}
......@@ -322,12 +287,12 @@ onMounted(() => {
display: block;
font-size: 24rpx;
margin-top: 8rpx;
opacity: 0.9;
// opacity: 0.9;
}
.floating-item {
position: absolute;
background: rgba(255, 255, 255, 0.9);
background: rgb(62, 144, 239);
border-radius: 50%;
display: flex;
align-items: center;
......@@ -335,33 +300,25 @@ onMounted(() => {
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;
color: #FFF;
}
.item-value {
display: block;
font-size: 28rpx;
font-weight: bold;
font-size: 22rpx;
line-height: 1;
}
.item-type {
display: block;
font-size: 20rpx;
font-size: 23rpx;
margin-top: 4rpx;
opacity: 0.7;
font-weight: bold;
// opacity: 0.7;
}
.stack-count {
......@@ -395,26 +352,18 @@ onMounted(() => {
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);
transform: translate(-50%, -50%) translateY(0px) rotate(0deg);
}
25% {
transform: translateY(-10px) rotate(1deg);
transform: translate(-50%, -50%) translateY(-10px) rotate(1deg);
}
50% {
transform: translateY(-5px) rotate(-1deg);
transform: translate(-50%, -50%) translateY(-5px) rotate(-1deg);
}
75% {
transform: translateY(-15px) rotate(0.5deg);
transform: translate(-50%, -50%) translateY(-15px) rotate(0.5deg);
}
}
......@@ -430,7 +379,7 @@ onMounted(() => {
}
.points-label {
font-size: 20rpx;
font-size: 24rpx;
}
}
</style>
......
......@@ -2,7 +2,7 @@
* index页面样式
*/
.index {
padding: 20px;
// padding: 20px;
.nut-button {
margin-bottom: 20px;
......
<!--
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-08-27 15:22:35
* @LastEditTime: 2025-08-27 17:13:11
* @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> -->
<!-- 积分收集组件 -->
<PointsCollector v-if="showCollector" />
<PointsCollector ref="pointsCollectorRef" height="30vh" />
<nut-button type="success" @click="handleCollectAll" style="margin-top: 20rpx;">一键收取</nut-button>
</view>
</template>
......@@ -25,18 +20,15 @@ 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 pointsCollectorRef = ref(null)
/**
* 显示积分收集组件
* 触发积分收集组件的一键收取
*/
const showPointsCollector = () => {
showCollector.value = !showCollector.value
const handleCollectAll = () => {
if (pointsCollectorRef.value) {
pointsCollectorRef.value.collectAll()
}
}
// 生命周期钩子
......