uniapp-幸运抽奖转盘组件-案例

使用案例

javascript 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import type { WheelPrize, WheelSpinResult } from '@/components/ui/game/lucky-wheel.vue'

const wheelRef = ref()

const prizeList: WheelPrize[] = [
  { prizeName: '一等奖', imageUrl: '/static/prize-1.png' },
  { prizeName: '二等奖', imageUrl: '/static/prize-2.png' },
  { prizeName: '三等奖', imageUrl: '/static/prize-3.png' },
  { prizeName: '四等奖', imageUrl: '/static/prize-4.png' },
  { prizeName: '五等奖', imageUrl: '/static/prize-5.png' },
  { prizeName: '六等奖', imageUrl: '/static/prize-6.png' },
]

function handleStart() {
  // 模拟请求接口得到中奖索引
  const targetIndex = 2
  wheelRef.value.spin(targetIndex)
}

function handleEnd(result: WheelSpinResult) {
  console.log('中奖:', result.prize.prizeName)
}
</script>

<template>
  <lucky-wheel
    ref="wheelRef"
    :list="prizeList"
    :size="600"
    :duration="5"
    btn-text="抽奖"
    :dot-blink="true"
    :scroll-to-top-on-end="true"
    :show-pointer="true"
    @before-start="handleStart"
    @end="handleEnd"
  >
    <template #item="{ item }">
      <text>{{ item.prizeName }}</text>
      <image :src="item.imageUrl" mode="aspectFit" />
    </template>
  </lucky-wheel>
</template>

组件介绍

lucky-wheel 是一个幸运转盘组件,支持自定义奖品列表、旋转动画、中心按钮样式、中奖高亮闪烁、指针指示等功能,适用于抽奖营销场景。

组件属性

属性 类型 默认值 说明
size Number 550 转盘尺寸(rpx)
list WheelPrize\[\] [] 奖品列表(不传或空数组时默认显示6个空扇区)
indexKey String '' 奖品列表中用于标识唯一索引的字段名,指定后 spin() 参数为该字段值
duration Number 5 旋转动画时长(秒)
btnText String '开始' 中心按钮文字
disabled Boolean false 是否禁用(禁止点击)
colors String\[\] ['#fef4e6', '#ffe8c3'] 奖品背景色列表,按顺序分配,不够则循环使用
dotBlink Boolean false 装饰点是否闪烁
btnBgImage String '' 中心按钮背景图(溢出自动裁剪)
highlightColor String 'rgb(255, 171, 95)' 中奖奖品闪烁背景色
innerDividerColor String '#FFFEFA' 奖品格之间的内边框颜色
outerCircleBg String '' 外圆盘背景色
scrollToTopOnEnd Boolean false 是否将中奖奖品旋转至正顶部(默认停在初始布局,高亮标识中奖)
showPointer Boolean false 是否显示顶部指针(需 scrollToTopOnEnd 为 true)

组件事件

事件 参数 说明
beforeStart - 点击开始按钮(旋转前触发,返回 false 可阻止旋转)
start - 开始旋转
end { prize: WheelPrize, targetIndex: number } 旋转结束,返回中奖奖品与索引

组件插槽

插槽名称 作用域 说明
item { item, index } 自定义奖品展示内容
btn - 自定义中心按钮内容
pointer - 自定义顶部指针内容(需 scrollToTopOnEnd + showPointer 为 true)

暴露方法

方法 参数 说明
spin target: number | string 开始旋转,参数为目标奖品索引或 indexKey 对应的字段值

内部类型定义

复制代码
interface WheelPrize {
  prizeName: string
  imageUrl: string
  entryId?: string
  [key: string]: any
}

interface WheelSpinResult {
  prize: WheelPrize
  targetIndex: number
}

组件封装

lucky-wheel

javascript 复制代码
<script setup lang="ts">
import { computed, ref } from 'vue'

export interface WheelPrize {
  prizeName: string
  imageUrl: string
  entryId?: string
  [key: string]: any
}

export interface WheelSpinResult {
  prize: WheelPrize
  targetIndex: number
}

const props = withDefaults(
  defineProps<{
    /** 转盘尺寸(rpx) */
    size?: number
    /** 奖品列表中用于标识唯一索引的字段名,指定后 spin() 参数为该字段值 */
    indexKey?: string
    /** 奖品列表(不传或传空数组时默认显示6个空扇区) */
    list?: WheelPrize[]
    /** 旋转动画时长(秒) */
    duration?: number
    /** 中心按钮文字 */
    btnText?: string
    /** 是否禁用(禁止点击) */
    disabled?: boolean
    /** 奖品背景色列表,按顺序分配,不够则循环使用 */
    colors?: string[]
    /** 装饰点是否闪烁 */
    dotBlink?: boolean
    /** 中心按钮背景图(溢出自动裁剪) */
    btnBgImage?: string
    /** 中奖奖品闪烁背景色 */
    highlightColor?: string
    /** 奖品格之间的内边框颜色 */
    innerDividerColor?: string
    /** 外圆盘背景色 */
    outerCircleBg?: string
    /** 是否将中奖奖品旋转至正顶部(默认停在初始布局,高亮标识中奖) */
    scrollToTopOnEnd?: boolean
    /** 是否显示顶部指针 */
    showPointer?: boolean
  }>(),
  {
    duration: 5,
    btnText: '开始',
    disabled: false,
    colors: () => ['#fef4e6', '#ffe8c3'],
    dotBlink: false,
    btnBgImage: '',
    highlightColor: 'rgb(255, 171, 95)',
    innerDividerColor: '#FFFEFA',
    outerCircleBg: '',
    scrollToTopOnEnd: false,
    showPointer: false,
    size: 550,
    indexKey: '',
  },
)

const emit = defineEmits<{
  /** 点击开始按钮(旋转前触发,可用于校验) */
  (e: 'beforeStart'): boolean | void
  /** 开始旋转 */
  (e: 'start'): void
  /** 旋转结束,返回结果 */
  (e: 'end', result: WheelSpinResult): void
}>()

const defaultEmptyList: WheelPrize[] = [
  { prizeName: '', imageUrl: '' },
  { prizeName: '', imageUrl: '' },
  { prizeName: '', imageUrl: '' },
  { prizeName: '', imageUrl: '' },
  { prizeName: '', imageUrl: '' },
  { prizeName: '', imageUrl: '' },
]

const resolvedList = computed(() =>
  !props.list || props.list.length === 0 ? defaultEmptyList : props.list,
)

const prizeLength = computed(() => resolvedList.value.length)

const isSpinning = ref(false)
const currentRotateDeg = ref(0)
const highlightIndex = ref(-1)

const pointerSvg = `data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTAyNCAxMDI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik01MTIgMTAyNGMtMjA1Ljc2LTI0MS40NzItMzI2LjI3Mi00MTEuNzEyLTM2MS42NjQtNTEwLjcyYTM4NCAzODQgMCAxIDEgNzIzLjA3MiAwLjgzMkM4MzcuNzYgNjEyLjkyOCA3MTcuMzEyIDc4Mi44NDggNTEyIDEwMjQuMDY0eiIgZmlsbD0iI2FhNjExNiIvPjxjaXJjbGUgY3g9IjUxMiIgY3k9IjQxMCIgcj0iMTQwIiBmaWxsPSIjZmZmZmZmIi8+PC9zdmc+`

/* 中心按钮样式 */
const btnStyle = computed(() => {
  if (!props.btnBgImage) {
    return {}
  }
  return {
    'background-image': `url(${props.btnBgImage})`,
    'background-size': 'cover',
    'background-position': 'center',
    'background-repeat': 'no-repeat',
    'background-clip': 'padding-box',
    'border': 'none',
  }
})

/* 外圆盘样式 */
const defaultCircleBg = `linear-gradient(145deg, #ff9800 15%, #ffb74d 45%, #ff6f00 65%, #ffe0b2 100%)`

const discStyle = computed(() => ({
  background: props.outerCircleBg || defaultCircleBg,
}))

/* CSS 变量:锥形渐变 & 旋转角度 */
const cssVar = computed(() => {
  const num = prizeLength.value
  if (num === 0) {
    const s = props.size / 670
    return {
      '--zp-panel-bg': '#fef4e6',
      '--zp-panel-deg': '0deg',
      '--zp-duration': `${props.duration}s`,
      '--zp-easing': 'cubic-bezier(0.35, 0, 0.25, 1)',
      '--zp-highlight-color': props.highlightColor,
      '--zp-wheel-size': `${props.size}rpx`,
      '--zp-disc-pd': `${45 * s}rpx`,
      '--zp-panel-bd': `${10 * s}rpx`,
      '--zp-item-pt': `${24 * s}rpx`,
      '--zp-img-size': `${80 * s}rpx`,
      '--zp-img-mt': `${10 * s}rpx`,
      '--zp-name-mt': `${12 * s}rpx`,
      '--zp-name-fs': `${24 * s}rpx`,
      '--zp-btn-size': `${150 * s}rpx`,
      '--zp-btn-fs': `${40 * s}rpx`,
    }
  }
  const deg = 360 / num
  const gapDeg = 5
  const effectiveDeg = deg - gapDeg
  const colorList = props.colors

  const result: string[] = []
  for (let i = 0; i < num; i += 1) {
    const c1 = colorList[i % colorList.length]
    const startDeg = i * deg
    const endDeg = startDeg + effectiveDeg
    const gapEndDeg = startDeg + deg

    result.push(`${c1} ${startDeg}deg ${endDeg}deg`)
    result.push(`${props.innerDividerColor} ${gapEndDeg}deg ${gapEndDeg}deg`)
  }

  const s = props.size / 670

  return {
    '--zp-panel-bg': `conic-gradient(from -${deg / 2}deg, ${result.join(', ')})`,
    '--zp-panel-deg': `${currentRotateDeg.value}deg`,
    '--zp-duration': `${props.duration}s`,
    '--zp-easing': 'cubic-bezier(0.35, 0, 0.25, 1)',
    '--zp-highlight-color': props.highlightColor,
    '--zp-wheel-size': `${props.size}rpx`,
    '--zp-disc-pd': `${45 * s}rpx`,
    '--zp-panel-bd': `${10 * s}rpx`,
    '--zp-item-pt': `${24 * s}rpx`,
    '--zp-img-size': `${80 * s}rpx`,
    '--zp-img-mt': `${10 * s}rpx`,
    '--zp-name-mt': `${12 * s}rpx`,
    '--zp-name-fs': `${24 * s}rpx`,
    '--zp-btn-size': `${150 * s}rpx`,
    '--zp-btn-fs': `${40 * s}rpx`,
  }
})

/* 扇形裁剪,防止高亮白色溢出到相邻奖品 */
const wedgeClip = computed(() => {
  const num = prizeLength.value
  if (num <= 2) {
    return ''
  }
  const deg = 360 / num
  const halfTan = Math.tan((deg / 2) * (Math.PI / 180))
  const left = 50 - halfTan * 50
  const right = 50 + halfTan * 50
  return `polygon(50% 100%, ${left}% 0%, ${right}% 0%)`
})

const wrapStyle = computed(() => ({
  width: `${props.size}rpx`,
  height: `${props.size}rpx`,
}))

/* 本次要停到的索引 */
const targetIndex = ref(0)

/**
 * 开始旋转(由父组件调用)
 * @param target 目标奖品索引或标识值(indexKey 指定时传入对应字段值)
 */
function spin(target: number | string) {
  if (isSpinning.value) {
    return
  }

  let targetIdx: number
  if (props.indexKey) {
    targetIdx = resolvedList.value.findIndex(item => item[props.indexKey] === target)
    if (targetIdx === -1) {
      console.warn(`[lucky-wheel] indexKey "${props.indexKey}" value "${target}" not found in list`)
      return
    }
  }
  else {
    targetIdx = target as number
    if (targetIdx < 0 || targetIdx >= prizeLength.value) {
      return
    }
  }

  highlightIndex.value = -1
  isSpinning.value = true

  targetIndex.value = targetIdx

  const deg = 360 / prizeLength.value
  let landingAngle: number
  if (props.scrollToTopOnEnd) {
    landingAngle = 360 - targetIdx * deg
  }
  else {
    landingAngle = 0
  }
  const extraSpins = 5
  const currentCycle = Math.floor(currentRotateDeg.value / 360)
  let newTotal = (currentCycle + extraSpins) * 360 + landingAngle
  while (newTotal <= currentRotateDeg.value) {
    newTotal += 360
  }
  currentRotateDeg.value = newTotal
}

function handleStart() {
  if (isSpinning.value || props.disabled) {
    return
  }

  const result = emit('beforeStart')
  if (result === false) {
    return
  }

  emit('start')
}

function handleSpinEnd() {
  if (!isSpinning.value) {
    return
  }

  isSpinning.value = false

  highlightIndex.value = targetIndex.value

  const prize = resolvedList.value[targetIndex.value]
  emit('end', {
    prize,
    targetIndex: targetIndex.value,
  })
}

/* 计算每个装饰点的样式 */
function getDotStyle(index: number) {
  const num = prizeLength.value
  const deg = (360 / num / 2) * (index - 1)
  return {
    transform: `translateX(-50%) rotate(${deg}deg)`,
  }
}

defineExpose({
  /** 开始旋转 */
  spin,
  /** 是否正在旋转 */
  isSpinning,
})
</script>

<template>
  <view class="wheel-wrap" :style="[wrapStyle, cssVar]">
    <view
      class="wheel-disc" :class="{ 'wheel-disc--spin': isSpinning }" :style="[discStyle, cssVar]"
      @transitionend="handleSpinEnd"
    >
      <!-- 装饰点 -->
      <view
        v-for="i in prizeLength" :key="i" class="wheel-dot" :class="{ 'wheel-dot--static': !dotBlink }"
        :style="getDotStyle(i)"
      />

      <!-- 转盘内容 -->
      <view class="wheel-panel" :style="cssVar">
        <view
          v-for="(item, idx) in resolvedList" :key="idx" class="wheel-item" :class="{
            'wheel-item--highlight': highlightIndex === idx,
          }" :style="{
            transform: `rotateZ(${(360 / prizeLength) * idx}deg)`,
            clipPath: wedgeClip,
          }"
        >
          <slot name="item" :item="item" :index="idx">
            <text class="wheel-name">
              {{ item.prizeName }}
            </text>
            <image class="wheel-img" :src="item.imageUrl" mode="aspectFit" />
          </slot>
        </view>
      </view>
    </view>

    <!-- 开始按钮(固定不转) -->
    <view class="wheel-btn" :style="btnStyle" @click="handleStart">
      <slot name="btn">
        <text>{{ btnText }}</text>
      </slot>
    </view>

    <!-- 固定指针 -->
    <view v-if="scrollToTopOnEnd && showPointer" class="wheel-pointer">
      <slot name="pointer">
        <image class="wheel-pointer-img" :src="pointerSvg" />
      </slot>
    </view>
  </view>
</template>

<style lang="scss" scoped>
/* ========= Keyframes ========= */
@keyframes dotToggle {

  0%,
  49.9% {
    opacity: 1;
  }

  50%,
  100% {
    opacity: 0;
  }
}
@keyframes prizeHighlight {

  0% {
    background-color: transparent;
  }

  20% {
    background-color: var(--zp-highlight-color, rgba(255, 255, 255, 0.6));
  }

  35% {
    background-color: transparent;
  }

  55% {
    background-color: var(--zp-highlight-color, rgba(255, 255, 255, 0.6));
  }

  70% {
    background-color: transparent;
  }

  100% {
    background-color: var(--zp-highlight-color, rgba(255, 255, 255, 0.6));
  }
}

/* ========= 转盘容器 ========= */
.wheel-wrap {
  position: relative;
  margin: auto;
}

/* --- 转盘圆盘 --- */
.wheel-disc {
  position: relative;
  width: var(--zp-wheel-size);
  height: var(--zp-wheel-size);
  border-radius: 50%;
  padding: var(--zp-disc-pd);
  box-sizing: border-box;
  transform: rotateZ(var(--zp-panel-deg));
  transition: none;
}

.wheel-disc--spin {
  transition: transform var(--zp-duration) var(--zp-easing);
}

/* --- 装饰点 --- */
.wheel-dot {
  position: absolute;
  left: 50%;
  top: 12rpx;
  bottom: 12rpx;
  width: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  transform-origin: 50% 50%;
  animation: dotToggle 2s steps(1) infinite;
}

.wheel-dot--static {
  animation: none;
}

.wheel-dot:nth-child(odd) {
  animation-delay: -1s;
}

.wheel-dot--static:nth-child(odd) {
  animation-delay: 0;
}

.wheel-dot::before,
.wheel-dot::after {
  content: "";
  width: 20rpx;
  height: 20rpx;
  border-radius: 50%;
  box-shadow: 0px 0px 5px 0px #ff8000;
  background: #fffefa;
  filter: blur(1rpx);
}

/* --- 转盘面板 --- */
.wheel-panel {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  overflow: hidden;
  background: var(--zp-panel-bg);
  border: var(--zp-panel-bd) solid #ffffff;
  box-sizing: border-box;
  transform: translateZ(0);
}

/* --- 奖品项 --- */
.wheel-item {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 50%;
  transform-origin: 50% 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-top: var(--zp-item-pt);
  box-sizing: border-box;
  border-radius: 8rpx 8rpx 0 0;
}

.wheel-item--highlight {
  animation: prizeHighlight 0.9s ease-in-out 1 forwards;
}

.wheel-img {
  width: var(--zp-img-size);
  height: var(--zp-img-size);
  margin-top: var(--zp-img-mt);
}

.wheel-name {
  margin-top: var(--zp-name-mt);
  font-size: var(--zp-name-fs);
  color: #494949;
}

/* --- 固定指针(scrollToTopOnEnd 模式) --- */
.wheel-pointer {
  position: absolute;
  left: 50%;
  top: -21rpx;
  z-index: 10;
  transform: translateX(-50%);
  width: 72rpx;
  height: 84rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.wheel-pointer-img {
  width: 100%;
  height: 100%;
}

/* --- 开始按钮(转盘中心) --- */
.wheel-btn {
  position: absolute;
  left: 50%;
  top: 49%;
  width: var(--zp-btn-size);
  height: var(--zp-btn-size);
  transform: translate(-50%, -50%);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--zp-btn-fs);
  font-weight: bold;
  color: #ffffff;
  border-radius: 50%;
  z-index: 3;
  background: linear-gradient(145deg, #ffb74d 45%,  #ffe0b2 100%);
  border: 5rpx solid #fff;
}
</style>
相关推荐
ModyQyW10 小时前
vite-plugin-uni-manifest 更新了什么
前端·typescript·uni-app
PedroQue991 天前
v2.7.1:修复 H5 端返回死循环闪烁问题
前端·uni-app
lhldsg1 天前
AI零售系统实战指南:从架构设计到落地部署全解析
java·人工智能·小程序·uni-app·零售
RisunJan2 天前
uni-app 面试知识点梳理
面试·职场和发展·uni-app
郑州光合科技余经理3 天前
本地生活服务系统:模块边界与结算字段怎么拆
java·开发语言·前端·后端·系统架构·uni-app·php
lhldsg4 天前
家校托管互通系统开发实战:从需求分析到部署全指南
小程序·uni-app·需求分析
paopaokaka_luck4 天前
基于springboot3+vue3+uniapp的河南非遗数字图谱小程序(协同过滤算法、数字图谱展示、ECharts 图形化分析)
java·前端·spring boot·学习·小程序·uni-app·echarts
宠友信息5 天前
社区类源码开发实践中的仿小红书系统技术要点分析
java·spring boot·redis·mysql·uni-app·vue·内容运营
郑州光合科技余经理5 天前
本地生活平台搭建:统一订单表与多后台切换怎么拆
java·开发语言·前端·系统架构·uni-app·php·ai编程