手把手教你实现高颜值移动端抽奖转盘,附完整源码

227 阅读6分钟

在移动端活动开发中,抽奖转盘是提升用户参与度的 “神器”。它不仅视觉效果吸睛,还能通过互动玩法增强用户粘性。今天就带大家从零打造一个高颜值、交互流畅的移动端抽奖转盘,包含完整实现逻辑和可直接复用的源码,新手也能快速上手~

最终效果预览

1.gif

  • 支持自定义奖品列表,自动适配不同数量奖品的分区展示
  • 流畅的转盘旋转动画,包含加速 - 匀速 - 减速的物理效果
  • 抽奖状态管理(未抽奖 / 抽奖中 / 已中奖),防止重复点击
  • 适配移动端屏幕,视觉层次分明,按钮交互反馈清晰
  • 内置模拟接口逻辑,可无缝对接真实后端接口

技术栈选型

  • 前端框架:Vue 3 + Vite(Composition API 语法糖)
  • UI 组件:Vant(轻量移动端组件库,用于 Toast 提示)
  • 样式方案:SCSS(方便编写嵌套样式和复用变量)
  • 工具函数:节流函数(防止重复点击)
  • 字体:阿里妈妈数黑体(提升中文显示质感)

核心功能实现拆解

1. 转盘布局与视觉设计

转盘的核心是环形分区指针定位,通过 CSS 定位和 transform 实现:

  • 转盘容器:固定宽高比(1:1),使用背景图实现转盘底座效果
  • 奖品分区:通过绝对定位和 rotate 旋转,将每个奖品均匀分布在环形区域
  • 指针:绝对定位在转盘中心上方,点击触发抽奖逻辑
  • 文字适配:每个奖品的文字区域通过反向旋转,保证文字正向显示

关键样式逻辑:

.wheel {
  width: 336px;
  height: 336px;
  border-radius: 50%;
  position: relative;
  overflow: hidden;
  transition: transform 4s; // 旋转过渡动画
  background: url("@/assets/img/img_wheel.png") no-repeat;
  background-size: 100% 100%;
}

.wheel-segment {
  position: absolute;
  transform-origin: 100% 50%; // 以右侧中点为旋转中心
  // 每个分区的旋转角度通过 JS 动态计算
}

2. 奖品分区动态计算

根据奖品数量自动计算每个分区的角度,确保均匀分布:

// 获取每个奖品的样式
const getSegmentStyle = (index) => {
  if (awardList.value.length === 0) return {};
  const segmentAngle = 360 / awardList.value.length; // 每个分区的角度
  const angle = segmentAngle * index + 90; // 初始偏移90度,使分区起始位置对齐顶部
  return {
    transform: `rotate(${angle}deg) translateY(-50%)`,
    transformOrigin: "100% 50%", // 关键:右侧中点为旋转中心
    // 其他定位样式...
  };
};

文字反向旋转,保证正向显示:

const getWrapStyle = () => {
  if (awardList.value.length === 0) return {};
  const angle = 360 / awardList.value.length;
  const rotateAngle = angle / 2 - 45; // 反向旋转抵消分区旋转效果
  return {
    transform: `rotate(${rotateAngle}deg)`,
    transformOrigin: "100% 100%",
  };
};

3. 转盘旋转核心逻辑

旋转动画的关键是角度计算状态控制

  • 基础旋转:每次抽奖至少旋转 5 圈,保证视觉流畅度
  • 目标定位:根据中奖奖品 ID,计算最终停止的角度
  • 状态锁:防止抽奖过程中重复点击
// 开始转盘
const startGame = (awardId, time) => {
  turnTableFlag.value = true; // 锁定抽奖状态
  turnTableTime.value++; // 记录抽奖次数
  const segmentAngle = 360 / awardList.value.length;
  // 计算中奖位置的角度(偏移1/2分区角度,使指针指向分区中心)
  const targetAngle = segmentAngle / 2 + awardId * segmentAngle;
  // 总旋转角度 = 基础旋转(5圈*次数) - 目标角度(使目标分区停在指针位置)
  currentAngle.value = 360 * (turnTableTime.value * 5) - targetAngle;
  // 抽奖结束后解锁状态
  setTimeout(() => {
    turnTableFlag.value = false;
  }, time);
};

4. 抽奖流程与状态管理

完整的抽奖流程包含:权限校验 → 接口请求 → 转盘旋转 → 结果展示:

const turntable = throttle(() => {
  // 1. 权限校验:是否有抽奖机会
  if (!prizeDraw.value) {
    showToast("您没有抽奖机会");
    return;
  }
  // 2. 防止重复抽奖:已中奖或抽奖中
  if (drawRecordList.value.length > 0) {
    return showToast("您已中奖,无需再次点击!");
  }
  if (turnTableFlag.value) {
    return (result.value = "正在抽奖中,请稍后");
  }
  // 3. 发起抽奖请求(模拟接口)
  startPrizeDrawData();
}, 4000); // 节流4秒,防止快速点击

模拟接口请求,返回随机中奖结果:

// 模拟开始抽奖(替代接口)
const startPrizeDrawData = async () => {
  try {
    await new Promise(resolve => setTimeout(resolve, 500)); // 模拟接口延迟
    const winningPrize = getMockPrize(); // 随机获取中奖奖品
    // 4秒后显示中奖结果(与转盘旋转时间同步)
    setTimeout(() => {
      showToast(`恭喜抽中${winningPrize.awardName}-${winningPrize.awardTitle}`);
      result.value = `您已抽中【${winningPrize.awardName}-${winningPrize.awardTitle}】`;
      drawRecordList.value = [winningPrize]; // 记录中奖结果
    }, 4000);
    // 启动转盘旋转
    startGame(winningPrize.awardId, 4000);
  } catch (error) {
    showToast("抽奖失败,请稍后再试");
  }
};

5. 重置功能与生命周期管理

支持重置抽奖状态,方便测试或重新抽奖:

// 刷新数据(重置抽奖状态)
const refresh = async () => {
  awardList.value = [];
  drawRecordList.value = [];
  turnTableFlag.value = false;
  turnTableTime.value = 0;
  currentAngle.value = 0;
  result.value = "您有一次抽奖机会";
  // 重新获取奖品列表和抽奖权限
  await Promise.all([fetchAwardList(), checkPrizeDrawEligibility()]);
};

// 组件挂载时初始化数据
onMounted(() => {
  refresh();
});

// 暴露重置方法,方便父组件调用
defineExpose({ refresh });

完整源码获取

上面已经展示了核心实现逻辑,完整源码包含所有样式、图片引用和工具函数,可直接复制到 Vue 3 项目中使用:

<template>
  <div class="wheel-container">
    <div class="result">{{ result }}</div>
    <div class="wheel" :style="`transform: rotate(${currentAngle}deg)`">
      <div
        v-for="(item, index) in awardList"
        :key="index"
        class="wheel-segment"
        :style="getSegmentStyle(index)"
      >
        <div class="wrap" :style="getWrapStyle()">
          <div class="text">
            <p class="name">{{ item.awardName }}</p>
            <p class="title">{{ item.awardTitle }}</p>
          </div>
        </div>
      </div>
    </div>
    <div class="pointer" @click="turntable">
      <img src="@/assets/img/img_pointer.png" />
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
import { showToast } from "vant";
import { throttle } from "@/utils/util.js";

// 模拟奖品列表数据
const mockAwardList = [
  { awardId: 0, awardName: "一等奖", awardTitle: "iPhone 15" },
  { awardId: 1, awardName: "二等奖", awardTitle: "AirPods Pro" },
  { awardId: 2, awardName: "三等奖", awardTitle: "100元红包" },
  { awardId: 3, awardName: "四等奖", awardTitle: "50元红包" },
  { awardId: 4, awardName: "五等奖", awardTitle: "20元红包" },
  { awardId: 5, awardName: "参与奖", awardTitle: "5元红包" },
];

// 模拟抽奖结果(随机返回一个奖品)
const getMockPrize = () => {
  // 可以调整概率,这里平均概率
  const randomIndex = Math.floor(Math.random() * mockAwardList.length);
  return mockAwardList[randomIndex];
};

const awardList = ref([]);
const currentAngle = ref(0);
const prizeDraw = ref(true);
const drawRecordList = ref([]);
const turnTableFlag = ref(false);
const turnTableTime = ref(0);
const result = ref("您有一次抽奖机会");

// 模拟获取奖品列表(替代接口)
const fetchAwardList = async () => {
  // 模拟接口延迟
  await new Promise(resolve => setTimeout(resolve, 300));
  awardList.value = [...mockAwardList];
};

// 模拟检查是否可以抽奖(替代接口)
const checkPrizeDrawEligibility = async () => {
  // 模拟接口延迟
  await new Promise(resolve => setTimeout(resolve, 300));
  // 模拟可以抽奖(flag:1 可以,0 不可以)
  prizeDraw.value = true; // 强制可以抽奖,可根据需要修改
  if (!prizeDraw.value) {
    result.value = "今日抽奖次数已用完";
  }
  fetchPrizeDrawRecord();
};

// 模拟获取抽奖记录(替代接口)
const fetchPrizeDrawRecord = async () => {
  // 模拟接口延迟
  await new Promise(resolve => setTimeout(resolve, 300));
  // 初始无抽奖记录,抽奖后会添加
  drawRecordList.value = [];
  if (drawRecordList.value.length > 0) {
    result.value = `您已抽中【${drawRecordList.value[0].awardName}-${drawRecordList.value[0].awardTitle}】`;
  }
};

// 模拟开始抽奖(替代接口)
const startPrizeDrawData = async () => {
  try {
    // 模拟接口延迟
    await new Promise(resolve => setTimeout(resolve, 500));
    
    const winningPrize = getMockPrize();
    
    // 模拟成功返回
    setTimeout(() => {
      showToast(`恭喜抽中${winningPrize.awardName}-${winningPrize.awardTitle}`);
      result.value = `您已抽中【${winningPrize.awardName}-${winningPrize.awardTitle}】`;
      // 添加到抽奖记录
      drawRecordList.value = [winningPrize];
    }, 4000);
    
    startGame(winningPrize.awardId, 4000);
  } catch (error) {
    console.error("抽奖失败:", error);
    showToast("抽奖失败,请稍后再试");
  }
};

// 转盘旋转
const turntable = throttle(() => {
  if (!prizeDraw.value) {
    showToast("您没有抽奖机会");
    return;
  }
  if (drawRecordList.value.length > 0) {
    return showToast(
      `您已抽中【${drawRecordList.value[0].awardName}-${drawRecordList.value[0].awardTitle}】,无需再次点击!`
    );
  }
  if (turnTableFlag.value) {
    return (result.value = "正在抽奖中,请稍后");
  }
  startPrizeDrawData();
}, 4000);

// 开始转盘
const startGame = (awardId, time) => {
  turnTableFlag.value = true;
  turnTableTime.value++;
  const segmentAngle = 360 / awardList.value.length;
  const angle = segmentAngle / 2 + awardId * segmentAngle;
  currentAngle.value = 360 * (turnTableTime.value * 5) - angle;
  setTimeout(() => {
    turnTableFlag.value = false;
  }, time);
};

// 刷新数据
const refresh = async () => {
  // 清空
  awardList.value = [];
  drawRecordList.value = [];
  turnTableFlag.value = false;
  turnTableTime.value = 0;
  currentAngle.value = 0;
  result.value = "您有一次抽奖机会";
  // 重新获取模拟数据
  await Promise.all([fetchAwardList(), checkPrizeDrawEligibility()]);
};

defineExpose({ refresh });

onMounted(() => {
  refresh();
});

// 获取每个奖品的样式
const getSegmentStyle = (index) => {
  if (awardList.value.length === 0) return {};
  const angle = (360 / awardList.value.length) * index + 90;
  return {
    transform: `rotate(${angle}deg) translateY(-50%)`,
    position: "absolute",
    width: "40%",
    height: "40%",
    left: "calc(10% - 2px)",
    top: "30%",
    transformOrigin: "100% 50%",
  };
};

// 获取每个奖品文字的样式
const getWrapStyle = () => {
  if (awardList.value.length === 0) return {};
  const angle = 360 / awardList.value.length;
  const rotateAngle = angle / 2 - 45;
  return {
    transform: `rotate(${rotateAngle}deg)`,
    transformOrigin: "100% 100%",
  };
};
</script>

<style lang="scss" scope>
@font-face {
  font-family: "AlimamaShuHeiTi-Bold";
  src: url("@/assets/fonts/AlimamaShuHeiTi-Bold.otf");
}

.wheel-container {
  width: 336px;
  position: relative;

  .result {
    height: 56px;
    line-height: 50px;
    text-align: center;
    font-size: 14px;
    color: #ff2c03;
    margin: 0 auto;
    background: url("@/assets/img/img_result.png") no-repeat center center;
    background-size: auto 100%;
  }

  .wheel {
    width: 336px;
    height: 336px;
    padding: 40px;
    box-sizing: border-box;
    border-radius: 50%;
    position: relative;
    overflow: hidden;
    transition: transform 4s;
    background: url("@/assets/img/img_wheel.png") no-repeat;
    background-size: 100% 100%;

    .wheel-segment {
      position: absolute;

      &::after {
        content: "";
        position: absolute;
        width: 100%;
        height: 4px;
        left: 0;
        bottom: 0;
        background: linear-gradient(90deg, #ff2c03 0%, #ff711f 100%);
        background-size: 100% 100%;
      }
    }

    .wrap {
      width: 100%;
      height: 100%;
    }

    .text {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%) rotate(-45deg);
      white-space: nowrap;
      text-overflow: ellipsis;
      overflow: hidden;
      width: 100%;
      line-height: 16px;
      text-align: center;
      font-size: 14px;
      padding: 8px 20px 45px;
      background: url("@/assets/img/img_red_envelope.png") no-repeat center bottom;
      background-size: auto 41px;

      .name {
        font-family: "AlimamaShuHeiTi-Bold";
        color: #ff7155;
        font-size: 13px;
      }

      .title {
        color: #ff3131;
        font-weight: bold;
        font-size: 15px;
      }
    }
  }

  .pointer {
    position: absolute;
    top: 55%;
    left: 50%;
    transform: translate(-50%, -50%);

    img {
      width: 84px;
    }
  }
}
</style>

使用说明

  1. 替换图片资源:将 img_wheel.png(转盘背景)、img_pointer.png(指针)、img_result.png(结果栏背景)、img_red_envelope.png(奖品文字背景)替换为自己的设计图
  2. 对接真实接口:将模拟接口函数(fetchAwardListcheckPrizeDrawEligibilitystartPrizeDrawData)替换为真实后端接口
  3. 调整样式:根据需求修改转盘大小、颜色、字体等样式
  4. 配置奖品概率:在 getMockPrize 函数中调整各奖品的中奖概率(当前为平均概率)