在移动端活动开发中,抽奖转盘是提升用户参与度的 “神器”。它不仅视觉效果吸睛,还能通过互动玩法增强用户粘性。今天就带大家从零打造一个高颜值、交互流畅的移动端抽奖转盘,包含完整实现逻辑和可直接复用的源码,新手也能快速上手~
最终效果预览
- 支持自定义奖品列表,自动适配不同数量奖品的分区展示
- 流畅的转盘旋转动画,包含加速 - 匀速 - 减速的物理效果
- 抽奖状态管理(未抽奖 / 抽奖中 / 已中奖),防止重复点击
- 适配移动端屏幕,视觉层次分明,按钮交互反馈清晰
- 内置模拟接口逻辑,可无缝对接真实后端接口
技术栈选型
- 前端框架: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>
使用说明
- 替换图片资源:将
img_wheel.png(转盘背景)、img_pointer.png(指针)、img_result.png(结果栏背景)、img_red_envelope.png(奖品文字背景)替换为自己的设计图 - 对接真实接口:将模拟接口函数(
fetchAwardList、checkPrizeDrawEligibility、startPrizeDrawData)替换为真实后端接口 - 调整样式:根据需求修改转盘大小、颜色、字体等样式
- 配置奖品概率:在
getMockPrize函数中调整各奖品的中奖概率(当前为平均概率)