🎡 Vue3 + GSAP 打造极致用户体验的互动抽奖系统 🎡

3,235 阅读6分钟

Vue3 + GSAP 文章推荐: 前端动效天花板?GSAP带你突破极限!

大家好!今天给大家分享的是利用Vue3 + GSAP 设计和开发一个极致用户体验的转盘抽奖系统

一、需求分析

一个转盘抽奖功能应该具备哪些要素呢?

  • 首先肯定要有个转盘
  • 转盘中平均分配着奖品
  • 有个指针指向抽中的奖品
  • 有个点击开始抽奖的按钮,点击之后转盘开始转动,停下来的时候,指针指向的奖品就是中奖的奖品
  • 抽奖次数限制(最多可以抽几次)
  • 中奖概率计算

二、技术点分析

从上面的需求分析可以得到以下技术实现方案:

  • 转盘是个原型,可以用border-radius 实现
  • 平均分配的扇形实现:根据奖品的个数计算出每个扇形角度的大小,利用css 变量进行对每个扇形旋转角度进行赋值
  • 每个扇形如何实现: 给外层的圆形div 增加overflow: hidden, 再利用clip-path 属性设置及可以画出每一个扇形
  • 指针可以利用border 画出一个三角形来代表指针
  • 点击开始抽奖之后的旋转效果可以利用gsap,来实现,gsap 是一个非常强大的动画库,gsap可以很方便的控制动画效果。

三、代码实现

1. 初始化一个Vue3项目

pnpm create vite lottery --template 
cd lottery
pnpm install

2. 页面布局

<template>
  <div class="lottery-container">
    <h1>幸运大转盘</h1>
    <div class="prize-wheel-container">
      <div class="wheel" ref="wheelRef">
        <div
          v-for="(prize, index) in prizes"
          :key="index"
          class="prize-segment"
          :style="getSegmentStyle(index)"
        >
          <span class="prize-text">{{ prize.name }}</span>
          <div class="prize-icon" v-if="prize.icon">
            <img :src="requireImg(prize.icon)" />
          </div>
        </div>
      </div>
      <div class="pointer">
        <div class="pointer-arrow"></div>
        <div class="pointer-text" @click="startLottery">开始抽奖</div>
      </div>
      <div class="light-effect"></div>
    </div>
    <div class="lottery-info">
      <p>剩余抽奖次数: {{ lotteryTimes }}</p>
    </div>
  </div>
</template>
<script setup>
import { ref, computed } from 'vue'
// 奖品配置
const prizes = ref([
  {
    name: '一等奖',
    value: 'iPhone 15',
    icon: 'phone-15',
    color: '#FF5252',
  },
  { name: '谢谢参与', value: 'none', icon: 'xiexie', color: '#FFEB3B' },
  {
    name: '二等奖',
    value: '华为 mate40',
    icon: 'huawei-mate40',
    color: '#4CAF50',
  },
  {
    name: '优惠券',
    value: 'coupon',
    icon: 'coupon',
    color: '#2196F3',
  },
  {
    name: '三等奖',
    value: '小米10',
    icon: 'xiaomi-10',
    color: '#9C27B0',
  },
  {
    name: '四等奖',
    value: '华为平板',
    icon: 'huawei-pingban',
    color: '#795548',
  },
  {
    name: '五等奖',
    value: '现金200元',
    icon: 'xianjin',
    color: '#607D8B',
  },
])

const lotteryTimes = ref(3)

const segmentAngle = computed(() => 360 / prizes.value.length)
const getSegmentStyle = index => {
  const angle = index * segmentAngle.value

  return {
    '--rotate': `${angle}deg`,
    background: prizes.value[index].color,
    '--text-rotate': `${-segmentAngle.value / 2}deg`,
  }
}

// vue3+vite   实现动态加载图片
const requireImg = name => {
  return new URL(`./assets/${name}.svg`, import.meta.url).href
}

const startLottery = () => {}
</script>

<style scoped>
.lottery-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 30px;
  background: linear-gradient(15deg, #f5f7fa 0%, #c3cfe2 100%);
  min-height: 100vh;
}
.title {
  color: #333;
  margin-bottom: 30px;
  font-size: 28px;
  text-shadow: 1px 1px 2px rgba(0, 0, 0 0.1);
}
.prize-wheel-container {
  position: relative;
  width: 300px;
  height: 300px;
  margin: 60px auto 30px;
}
.particle {
  z-index: 99;
}
.wheel {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  position: relative;
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  border: 8px solid #fff;
  background: #fff;
}
.prize-segment {
  position: absolute;
  width: 50%;
  height: 50%;
  left: 0;
  top: 0;
  transform-origin: bottom right;
  transform: rotate(var(--rotate));
  display: flex;
  flex-direction: column;
  align-content: center;
  justify-content: flex-start;
  padding-top: 20px;
  padding-left: 64px;
  box-sizing: border-box;
  clip-path: polygon(-26% 0, 100% 0, 100% 100%);
}

.prize-text {
  transform: rotate(var(--text-rotate));
  width: 60px;
  text-align: center;
  font-weight: bold;
  color: #fff;
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
  font-size: 14px;
  margin-bottom: 5px;
}
.prize-icon {
  transform: rotate(var(--text-rotate));
  color: white;
  font-size: 24px;
  text-align: center;
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
}
.prize-icon img {
  width: 40px;
}
.pointer {
  position: absolute;
  top: -30px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
  cursor: pointer;
  transition: all 0.3s;
}
.pointer:hover {
  transform: translateX(-50%) scale(1.1);
}
.pointer-arrow {
  width: 0;
  height: 0;
  border-left: 25px solid transparent;
  border-right: 25px solid transparent;
  border-top: 50px solid #e91e63;
  filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.3));
}
.pointer-text {
  position: absolute;
  top: -40px;
  left: 50%;
  transform: translateX(-50%);
  background: #e91e63;
  color: #fff;
  padding: 5px 15px;
  border-radius: 20px;
  font-weight: bold;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
  white-space: nowrap;
}

.light-effect {
  position: absolute;
  top: -20px;
  left: -20px;
  right: -20px;
  bottom: -20px;
  background: radial-gradient(
    circle,
    rgba(255, 255, 255, 0.8) 0%,
    rgba(255, 255, 255, 0) 70%
  );
}
.lottery-info {
  background: white;
  padding: 10px 20px;
  border-radius: 20px;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
  font-size: 16px;
  color: #666;
}
</style>


以上代码中:

  • segmentAngle 计算属性 根据奖品个数计算出一个扇形所站的圆心角
  • getSegmentStyle 方法 通过segmentAngle 和index 计算出每个扇形需要旋转的角度,在计算文案需要旋转的角度,在模板中调用该方法赋值给style,这样每个扇形就拥有了对应的css 变量
  • 在css 中 通过rotate(var(--rotate)) 和得到扇形旋转角度和文案旋转角度
  • 在css 中使用clip-pathoverflow 属性设置得到想要的扇形

3. 添加点击开始抽抽奖的功能

前面说过点击开始抽奖功能之后转盘的旋转特效使用GSAP来实现,因为GSAP 动画效果丰富,且非常方便控制。所以先来安装下GSAP

执行 pnpm install gsap

代码实现:

<template>
  <div class="lottery-container">
    <h1>幸运大转盘</h1>
    <div class="prize-wheel-container">
      <div class="wheel" ref="wheelRef">
        <div
          v-for="(prize, index) in prizes"
          :key="index"
          class="prize-segment"
          :style="getSegmentStyle(index)"
        >
          <span class="prize-text">{{ prize.name }}</span>
          <div class="prize-icon" v-if="prize.icon">
            <img :src="requireImg(prize.icon)" />
          </div>
        </div>
      </div>
      <div class="pointer">
        <div class="pointer-arrow"></div>
        <div class="pointer-text" @click="startLottery">开始抽奖</div>
      </div>
      <div class="light-effect"></div>
    </div>
    <div class="lottery-info">
      <p>剩余抽奖次数: {{ lotteryTimes }}</p>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { gsap } from 'gsap'
// 奖品配置
const prizes = ref([
  {
    name: '一等奖',
    value: 'iPhone 15',
    icon: 'phone-15',
    color: '#FF5252',
  },
  { name: '谢谢参与', value: 'none', icon: 'xiexie', color: '#FFEB3B' },
  {
    name: '二等奖',
    value: '华为 mate40',
    icon: 'huawei-mate40',
    color: '#4CAF50',
  },
  {
    name: '优惠券',
    value: 'coupon',
    icon: 'coupon',
    color: '#2196F3',
  },
  {
    name: '三等奖',
    value: '小米10',
    icon: 'xiaomi-10',
    color: '#9C27B0',
  },
  {
    name: '四等奖',
    value: '华为平板',
    icon: 'huawei-pingban',
    color: '#795548',
  },
  {
    name: '五等奖',
    value: '现金200元',
    icon: 'xianjin',
    color: '#607D8B',
  },
])

const segmentAngle = computed(() => 360 / prizes.value.length)
const getSegmentStyle = index => {
  const angle = index * segmentAngle.value

  return {
    '--rotate': `${angle}deg`,
    background: prizes.value[index].color,
    '--text-rotate': `${-segmentAngle.value / 2}deg`,
  }
}

// vue3+vite   实现动态加载图片
const requireImg = name => {
  return new URL(`./assets/${name}.svg`, import.meta.url).href
}

const lotteryTimes = ref(3) // 剩余抽奖次数
const wheelRef = ref(null)
const isRotating = ref(false)
const currentRotation = ref(0)

const startLottery = async () => {
  // 如果在抽奖过程中或者剩余抽奖次数为0则不做任何操作
  if (isRotating.value || lotteryTimes.value <= 0) return

  isRotating.value = true
  lotteryTimes.value--
  // 模拟API 请求获取抽奖结果
  const restult = await mockLotteryAPI()
  const winnerIndex = prizes.value.findIndex(p => p.value === restult.prize)
  const centerAngel = segmentAngle.value / 2
  // 计算目标旋转角度(5圈) + 目标角度
  const targetAngle = 360 * 5 + winnerIndex * segmentAngle.value - centerAngel

  // 创建GSAP动画时间轴
  const tl = gsap.timeline({
    onComplete: () => {
      showPrizeAlert(winnerIndex)
      isRotating.value = false
    },
  })
  tl.to(wheelRef.value, {
    rotation: currentRotation.value + 360 * 2 + centerAngel,
    duration: 2,
    ease: 'power2.inOut',
    modifiers: {
      rotating: r => {
        r = parseFloat(r) % 360
        return r + 'deg'
      },
    },
  }).to(
    wheelRef.value,
    {
      rotation: -targetAngle,
      duration: 3,
      ease: 'elastic.out(1, 0.5)',
      modifiers: {
        rotating: r => {
          r = parseFloat(r) * 360
          currentRotation.value = r
          return r + 'deg'
        },
      },
    },
    '>'
  )
}

// 模拟API请求的方法
const mockLotteryAPI = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      // 这里模拟一个有概率的抽奖结果
      const random = Math.random()
      let prizeValue

      if (random < 0.05) prizeValue = 'iPhone 15' // 5% 一等奖
      else if (random < 0.08) prizeValue = '华为 mate40' // 8% 二等奖
      else if (random < 0.15) prizeValue = '小米10' // 7% 三等奖
      else if (random < 0.25) prizeValue = '华为平板' // 10% 四等奖
      else if (random < 0.45) prizeValue = '现金200元' // 20% 五等奖
      else if (random < 0.7) prizeValue = 'coupon' // 25% 优惠券
      else prizeValue = 'none' // 35% 谢谢参与

      resolve({ prize: prizeValue })
    }, 500)
  })
}

// 中奖信息提示
const showPrizeAlert = index => {
  const prize = prizes.value[index]
  if (prize.value === 'none') {
    alert('很遗憾,这次没有中奖,下次再接再厉!')
  } else {
    alert(`恭喜您获得:${prize.name}, ${prize.value}一个`)
  }
}

onMounted(() => {
  gsap.set(wheelRef.value, { rotation: segmentAngle.value / 2 })
})
</script>

<style scoped>
.lottery-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 30px;
  background: linear-gradient(15deg, #f5f7fa 0%, #c3cfe2 100%);
  min-height: 100vh;
}
.title {
  color: #333;
  margin-bottom: 30px;
  font-size: 28px;
  text-shadow: 1px 1px 2px rgba(0, 0, 0 0.1);
}
.prize-wheel-container {
  position: relative;
  width: 300px;
  height: 300px;
  margin: 60px auto 30px;
}
.particle {
  z-index: 99;
}
.wheel {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  position: relative;
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  border: 8px solid #fff;
  background: #fff;
}
.prize-segment {
  position: absolute;
  width: 50%;
  height: 50%;
  left: 0;
  top: 0;
  transform-origin: bottom right;
  transform: rotate(var(--rotate));
  display: flex;
  flex-direction: column;
  align-content: center;
  justify-content: flex-start;
  padding-top: 20px;
  padding-left: 64px;
  box-sizing: border-box;
  clip-path: polygon(-26% 0, 100% 0, 100% 100%);
}

.prize-text {
  transform: rotate(var(--text-rotate));
  width: 60px;
  text-align: center;
  font-weight: bold;
  color: #fff;
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
  font-size: 14px;
  margin-bottom: 5px;
}
.prize-icon {
  transform: rotate(var(--text-rotate));
  color: white;
  font-size: 24px;
  text-align: center;
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
}
.prize-icon img {
  width: 40px;
}
.pointer {
  position: absolute;
  top: -30px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
  cursor: pointer;
  transition: all 0.3s;
}
.pointer:hover {
  transform: translateX(-50%) scale(1.1);
}
.pointer-arrow {
  width: 0;
  height: 0;
  border-left: 25px solid transparent;
  border-right: 25px solid transparent;
  border-top: 50px solid #e91e63;
  filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.3));
}
.pointer-text {
  position: absolute;
  top: -40px;
  left: 50%;
  transform: translateX(-50%);
  background: #e91e63;
  color: #fff;
  padding: 5px 15px;
  border-radius: 20px;
  font-weight: bold;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
  white-space: nowrap;
}

.light-effect {
  position: absolute;
  top: -20px;
  left: -20px;
  right: -20px;
  bottom: -20px;
  background: radial-gradient(
    circle,
    rgba(255, 255, 255, 0.8) 0%,
    rgba(255, 255, 255, 0) 70%
  );
}
.lottery-info {
  background: white;
  padding: 10px 20px;
  border-radius: 20px;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
  font-size: 16px;
  color: #666;
}
</style>


在上述代码中:

  • 导入了gsap
  • 点击开始抽奖调用了startLottery方法, 在startLottery 方法中
    • 先判断上次抽奖是否结束,如果没有结束或者抽奖剩余次数为0 则直接返回,不做任何操作

    • 接着调用了mockLotteryAPI 方法,mockLotteryAPI 是一个模拟api请求的方法,得到获奖对应的奖品名称

    • 根据模拟请求得到的奖品名称匹配得到对应奖品信息的索引,计算出中奖的奖品需要旋转多少角度到达指针对应的位置

    • 调用gsap的timeline方法得到一个时间轴动画

    • 调用gsap的to方法处理旋转动画

    • timeline 上的动画执行完成之后会调用 onComplete 回调方法

    • 在onComplete 方法中就可以调用showPrizeAlert 方法告诉用户中奖的信息

    • 重置isRotating 的值告诉用户可以进行再次抽奖了 效果演示:

choujiang.gif

四、总结

今天主要分享了开发一个抽奖功能的过程:

  • 扇形布局技巧
  • 抽奖旋转角度计算技巧
  • gsap 实现动画特效的方法

今天的分享就到这里了,感谢收看。