<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>