Commit 0519d87a by lijiabin

【需求 20520】 feat: 完成前台大转盘功能

parent feb96717
<template>
<div class="lucky-wheel-wrapper">
<div class="lucky-wheel-wrapper" :class="{ 'scale-80': smallerThanXl }">
<LuckyWheel
ref="myLucky"
width="260px"
height="260px"
:blocks="blocks"
:buttons="buttons"
:prizes="prizes"
:prizes="computedPrizes"
:default-config="defaultConfig"
:default-style="defaultStyle"
@start="startCallback"
@end="endCallback"
/>
<!-- 自定义指针 -->
......@@ -21,7 +20,7 @@
class="go-btn"
:class="{ 'is-spinning': isSpinning }"
:disabled="isSpinning"
@click="startCallback"
@click="popClick"
>
<span class="go-text">GO</span>
</button>
......@@ -29,11 +28,22 @@
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { LuckyWheel } from '@lucky-canvas/vue'
import ringTexture from '@/assets/img/lucky-wheel-outer-ring.svg'
import type { WheelPrizeItemDto, LuckWheelResultDto } from '@/api'
import { participateLuckyWheel, getWheelPrizeList } from '@/api'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useMessageBox } from '@/hooks'
const { confirm } = useMessageBox()
const breakpoints = useBreakpoints(breakpointsTailwind)
const smallerThanXl = breakpoints.smaller('xl')
const wheelPrizeList = ref<WheelPrizeItemDto[]>([])
const emit = defineEmits<{
(e: 'prize', prize: Record<string, unknown>): void
handlePrizeResult: [LuckWheelResultDto]
}>()
const myLucky = ref<InstanceType<typeof LuckyWheel>>()
......@@ -47,9 +57,9 @@ const defaultConfig = {
const defaultStyle = {
fontColor: '#333',
fontSize: '12px',
fontWeight: 'bold',
lineHeight: '14px',
lengthLimit: '60%',
}
const blocks = [
......@@ -69,102 +79,37 @@ const blocks = [
{ padding: '3px', background: '#7b6fef' },
]
const prizes = [
{
background: '#ffffff',
fonts: [{ text: '谢谢参与', top: '12%', fontSize: '11px', fontColor: '#6858ec' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f60a.png',
width: '30%',
top: '38%',
},
],
},
{
background: '#f3f0ff',
fonts: [{ text: '10个京豆', top: '12%', fontSize: '11px', fontColor: '#e5a012' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f61d.png',
width: '30%',
top: '38%',
},
],
},
{
background: '#ffffff',
fonts: [{ text: '5个京豆', top: '12%', fontSize: '11px', fontColor: '#e5a012' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f929.png',
width: '30%',
top: '38%',
},
],
},
{
background: '#f3f0ff',
fonts: [{ text: '1个京豆', top: '12%', fontSize: '11px', fontColor: '#e5a012' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f60e.png',
width: '30%',
top: '38%',
},
],
},
{
background: '#ffffff',
fonts: [{ text: '谢谢参与', top: '12%', fontSize: '11px', fontColor: '#6858ec' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f385.png',
width: '30%',
top: '38%',
},
],
},
{
background: '#f3f0ff',
fonts: [{ text: '10个京豆', top: '12%', fontSize: '11px', fontColor: '#e5a012' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f917.png',
width: '30%',
top: '38%',
},
],
},
{
background: '#ffffff',
fonts: [{ text: '5个京豆', top: '12%', fontSize: '11px', fontColor: '#e5a012' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f618.png',
width: '30%',
top: '38%',
},
],
},
{
background: '#f3f0ff',
fonts: [{ text: '1个京豆', top: '12%', fontSize: '11px', fontColor: '#e5a012' }],
imgs: [
{
src: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f970.png',
width: '30%',
top: '38%',
},
],
},
]
const computedPrizes = computed(() => {
const width = wheelPrizeList.value.length >= 6 ? '37%' : '30%'
return wheelPrizeList.value.map((item: WheelPrizeItemDto, index: number) => {
return {
background: index % 2 === 0 ? '#ffffff' : '#f3f0ff',
fonts: [
{
text: item.name,
top: '8%',
fontSize: '10px',
fontColor: index % 2 === 0 ? '#6858ec' : '#e5a012',
lineClamp: 1,
},
],
imgs: [
{
src: item.imageUrl,
width,
top: '35%',
},
],
id: item.id,
}
})
})
const buttons = [
{ radius: '30%', background: '#fce4e4' },
{ radius: '25%', background: '#f75a5a' },
{ radius: '25%', background: '#fce4e4' },
{ radius: '20%', background: '#f75a5a' },
{
radius: '22%',
radius: '18%',
background: '#e63939',
pointer: false,
fonts: [{ text: '', top: '-14px' }],
......@@ -173,20 +118,70 @@ const buttons = [
const isSpinning = ref(false)
const startCallback = () => {
let resultPrize: null | LuckWheelResultDto = null
const startCallback = async () => {
if (isSpinning.value) return
isSpinning.value = true
myLucky.value?.play()
setTimeout(async () => {
const { data } = await participateLuckyWheel()
const idx = computedPrizes.value.findIndex((item) => item.id === data.prizeId)
myLucky.value?.stop(idx)
resultPrize = data
}, 1000)
}
// 获取最新的奖品列表 对比 是否更新了
const popClick = async () => {
if (isSpinning.value) return
const { data } = await getWheelPrizeList()
const newWheelPrizeList = data
const oldWheelPrizeList = wheelPrizeList.value
// 暂时只需要对比 1长度不一致 需要更新 2如果长度一样 如果有一组的名字或者图片 不一样 需要更新
let shouldUpdate = false
if (newWheelPrizeList.length !== oldWheelPrizeList.length) {
shouldUpdate = true
} else {
newWheelPrizeList.forEach((item: WheelPrizeItemDto, index: number) => {
if (
item.name !== oldWheelPrizeList[index]?.name ||
item.imageUrl !== oldWheelPrizeList[index]?.imageUrl
) {
shouldUpdate = true
}
})
}
if (shouldUpdate) {
// 给用户提示 奖池更新了 请重新点击
await confirm({
title: '检测到后台奖池有更新',
message: '请重新点击按钮开始抽奖',
type: 'warning',
showCancelButton: false,
})
wheelPrizeList.value = newWheelPrizeList
} else {
startCallback()
}
}
const endCallback = () => {
setTimeout(() => {
const index = Math.floor(Math.random() * prizes.length)
myLucky.value?.stop(index)
}, 3000)
isSpinning.value = false
emit('handlePrizeResult', resultPrize as LuckWheelResultDto)
}, 500)
}
const endCallback = (prize: Record<string, unknown>) => {
isSpinning.value = false
emit('prize', prize)
const initWheelPrizeList = async () => {
const { data } = await getWheelPrizeList()
wheelPrizeList.value = data
}
onMounted(() => {
initWheelPrizeList()
})
</script>
<style scoped>
......@@ -206,8 +201,8 @@ const endCallback = (prize: Record<string, unknown>) => {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
width: 50px;
height: 50px;
border-radius: 50%;
border: 3px solid #ff8a80;
background: linear-gradient(145deg, #ff5252, #d32f2f);
......
<template>
<!-- 转盘容器 -->
<Comp @prize="handlePrize" />
<!-- 🎉 中奖动画层 -->
<Comp @handle-prize-result="handlePrizeResult" />
<el-button @click="testAnimation">测试动画</el-button>
<Teleport to="body">
<Transition name="result">
<div
v-if="showResult"
class="fixed inset-0 z-[9999] bg-black/70 backdrop-blur flex flex-col items-center justify-center"
>
<img :src="currentPrize?.img" class="w-32 h-32 animate-pop" />
<!-- 调整中奖图片尺寸 -->
<div class="mt-6 text-white text-3xl font-bold">恭喜获得 {{ currentPrize?.name }}</div>
<!-- 调整中奖文字尺寸 -->
<el-button class="mt-8" type="primary" @click="showResult = false"> 知道了 </el-button>
<template v-if="isThanksPrize">
<div ref="blessingWrapRef" class="blessing-wrap">
<div ref="blessingGlowRef" class="blessing-glow" aria-hidden="true" />
<div ref="blessingFanSceneRef" class="blessing-fan-scene">
<div ref="blessingTagSlotRef" class="blessing-tag-slot" aria-hidden="true" />
<div ref="blessingFanRef" class="blessing-fan" aria-hidden="true">
<div
v-for="(text, index) in fanStackTexts"
:key="`${text}-${index}`"
:ref="setFanItemRef"
class="blessing-fan-item"
:style="fanItemStyle(index, fanStackTexts.length)"
:data-angle="fanItemMotion(index, fanStackTexts.length).angle"
:data-x="fanItemMotion(index, fanStackTexts.length).xOffset"
:data-y="fanItemMotion(index, fanStackTexts.length).yOffset"
:data-scale="fanItemMotion(index, fanStackTexts.length).scale"
>
<div class="blessing-fan-face">
<span class="blessing-fan-emoji">{{ fanEmoji(index) }}</span>
<div class="blessing-fan-text">
<span
v-for="(char, charIndex) in text.split('')"
:key="`${char}-${charIndex}`"
class="blessing-fan-char"
>
{{ char }}
</span>
</div>
</div>
</div>
</div>
<div ref="blessingTagStageRef" class="blessing-tag-stage">
<div ref="blessingTagRef" class="blessing-tag">
<div ref="blessingTagTopRef" class="blessing-tag-top">
<span class="blessing-tag-hole" />
<span class="blessing-tag-knot" />
</div>
<div class="blessing-tag-headline">LUCKY</div>
<div class="blessing-tag-divider" />
<div class="blessing-text" :class="{ 'is-long-text': blessingChars.length > 10 }">
<span
v-for="(char, index) in blessingChars"
:key="`${char}-${index}`"
:ref="setBlessingCharRef"
>
{{ char }}
</span>
</div>
<div ref="blessingSealRef" class="blessing-tag-seal">GOOD DAY</div>
</div>
</div>
</div>
</div>
<div ref="blessingCaptionRef" class="blessing-caption">
<p class="blessing-caption-title">今日签语</p>
<p class="blessing-caption-subtitle">抽一支好运电子签,把今天过得亮一点</p>
</div>
<el-button class="mt-8" type="primary" @click="showResult = false">知道了</el-button>
</template>
<template v-else>
<img :src="currentPrize?.prizeImageUrl" class="w-60 h-60 animate-pop" />
<div class="mt-6 text-white text-3xl font-bold">
恭喜获得 {{ currentPrize?.prizeName }}
</div>
<el-button class="mt-8" type="primary" @click="showResult = false">知道了</el-button>
</template>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Comp from './Comp.vue'
interface Prize {
name: string
img: string
}
const prizes: Prize[] = [
{ name: '100元优惠券', img: 'https://cdn-icons-png.flaticon.com/512/1041/1041916.png' },
{ name: '谢谢参与', img: 'https://cdn-icons-png.flaticon.com/512/1828/1828843.png' },
// 修复了图片链接中的拼写错误 'flaticom' -> 'flaticon'
{ name: '实物奖品', img: 'https://cdn-icons-png.flaticon.com/512/15/15874.png' },
{ name: '红包', img: 'https://cdn-icons-png.flaticon.com/512/2331/2331970.png' },
{ name: '幸运奖', img: 'https://cdn-icons-png.flaticon.com/512/744/744465.png' },
{ name: '平板电脑', img: 'https://cdn-icons-png.flaticon.com/512/1828/1828919.png' },
]
import type { ComponentPublicInstance } from 'vue'
import gsap from 'gsap'
import Comp from './components/LuckyWheel.vue'
import type { LuckWheelResultDto } from '@/api'
const rotateDeg = ref(0)
const isDrawing = ref(false)
const showResult = ref(false)
const currentPrize = ref<Prize | null>(null)
const currentPrize = ref<LuckWheelResultDto | null>(null)
function startDraw() {
if (isDrawing.value) return
const blessingWrapRef = ref<HTMLElement | null>(null)
const blessingGlowRef = ref<HTMLElement | null>(null)
const blessingFanSceneRef = ref<HTMLElement | null>(null)
const blessingFanRef = ref<HTMLElement | null>(null)
const blessingTagSlotRef = ref<HTMLElement | null>(null)
const blessingTagStageRef = ref<HTMLElement | null>(null)
const blessingTagRef = ref<HTMLElement | null>(null)
const blessingTagTopRef = ref<HTMLElement | null>(null)
const blessingSealRef = ref<HTMLElement | null>(null)
const blessingCaptionRef = ref<HTMLElement | null>(null)
const fanItemRefs = ref<HTMLElement[]>([])
const blessingCharRefs = ref<HTMLElement[]>([])
isDrawing.value = true
const prizeIndex = Math.floor(Math.random() * prizes.length)
currentPrize.value = prizes[prizeIndex]
let blessingTimeline: gsap.core.Timeline | null = null
let floatTween: gsap.core.Tween | null = null
const perDeg = 360 / prizes.length
const blessingEmojiList = ['·ᴗ·', '^_^', '◕‿◕', '✦ᴗ✦', '˶ᵔ ᵕ ᵔ˶', '•‿•', '๑´ڡ`๑']
// 计算奖品中心点相对于转盘0度(3点钟方向)的绝对角度
const targetPrizeCenterAngle = prizeIndex * perDeg + perDeg / 2
const isThanksPrize = computed(() => currentPrize.value?.prizeId == null)
const blessingChars = computed(() => {
const text = (currentPrize.value?.blessingText || '').trim()
return text ? text.split('') : ['好', '运', '常', '在']
})
// 指针位于12点钟方向(转盘的-90度或270度),计算需要旋转的度数,使奖品中心对齐指针
// 旋转度数 = (指针目标角度 - 奖品中心角度 + 360) % 360
// 这里指针目标角度我们定为270度 (相对于3点钟的0度)
const rotationToAlignWithPointer = (270 - targetPrizeCenterAngle + 360) % 360
const fanPreviewTexts = [
'钱包鼓鼓 烦恼全无',
'年年有鱼摸 岁岁越平安',
'干啥啥都顺 吃嘛嘛都香',
'狂吃不胖 熬夜不秃',
'凡是发生皆有利于我',
'内核强大 所向披靡',
'逆风如解意 税后十个亿',
'恶缘退散 好人靠近',
'发发发 发量爆棚',
'桃旺旺 钱财多多',
'拒绝精神内耗 本人配享太庙',
]
// 增加多次完整旋转,使转盘转动效果更明显
const fullSpins = 5
const finalTargetDeg = fullSpins * 360 + rotationToAlignWithPointer
const fanStackTexts = computed(() => {
const texts = [...fanPreviewTexts]
const selectedText = (currentPrize.value?.blessingText || '').trim()
rotateDeg.value += finalTargetDeg
if (!isThanksPrize.value || !selectedText) {
return texts
}
setTimeout(() => {
showResult.value = true
isDrawing.value = false
}, 4200)
}
const selectedIndex = texts.indexOf(selectedText)
if (selectedIndex === -1) {
return texts
}
const [selectedItem] = texts.splice(selectedIndex, 1)
const centerIndex = Math.floor(texts.length / 2)
texts.splice(centerIndex, 0, selectedItem as string)
return texts
})
const fanItemMotion = (index: number, total: number) => {
const center = (total - 1) / 2
const offset = index - center
const spreadRatio = center === 0 ? 0 : offset / center
const angle = spreadRatio * 30
const xOffset = offset * 40
const yOffset = Math.abs(offset) * 4
const scale = 1 - Math.abs(spreadRatio) * 0.08
/**
* 每个奖品扇区的容器样式,负责整体旋转。
* 它的子元素 (prizeSliceStyle 和 prizeContentStyle) 将在此旋转坐标系中定位。
*/
function prizeSectorWrapperStyle(index: number) {
const angle = 360 / prizes.length
return {
transform: `rotate(${angle * index}deg)`, // 旋转整个逻辑扇区区域
transformOrigin: '50% 50%', // 围绕转盘中心旋转
angle,
xOffset,
yOffset,
scale,
}
}
/**
* 实际的扇形区域样式,负责背景和clipPath。
* 它的旋转由 prizeSectorWrapperStyle 继承。
*/
function prizeSliceStyle(index: number) {
const fanItemStyle = (index: number, total: number) => {
const { angle, xOffset, yOffset, scale } = fanItemMotion(index, total)
return {
background:
index % 2
? 'linear-gradient(135deg,#f43f5e,#fb7185)' // 调整渐变颜色,使其更协调
: 'linear-gradient(135deg,#f59e0b,#fbbf24)',
clipPath: 'polygon(50% 50%, 100% 0%, 100% 100%)', // 保持三角形裁剪,初始指向右侧
'--angle': `${angle}deg`,
'--x-offset': `${xOffset}px`,
'--y-offset': `${yOffset}px`,
'--scale': `${scale}`,
}
}
/**
* 奖品内容(图标和文字)的定位和旋转样式。
* 它在 prizeSectorWrapperStyle 已经旋转的坐标系中进行定位。
*/
function prizeContentStyle(index: number) {
const angle = 360 / prizes.length
const wheelDiameterRem = 48 // 转盘直径 48rem
const wheelRadiusRem = wheelDiameterRem / 2 // 转盘半径 24rem
// 将内容放置在距离中心约 65% 的半径处
const distanceFromCenterRem = wheelRadiusRem * 0.65
const fanEmoji = (index: number) => blessingEmojiList[index % blessingEmojiList.length]
return {
// 将内容div的中心放置在转盘的中心
top: '50%',
left: '50%',
// 1. translate(-50%, -50%):将div自身的中心对齐到 (top:50%, left:50%)
// 2. rotate(${angle / 2}deg):将其旋转到扇区的中心角度 (相对于扇区初始的0度,即右侧)
// 3. translateY(-${distanceFromCenterRem}rem):沿着旋转后的Y轴(即径向方向)向外平移
transform: `
translate(-50%, -50%)
rotate(${angle / 2}deg)
translateY(-${distanceFromCenterRem}rem)
`,
zIndex: '1', // 确保内容显示在扇区背景之上
}
}
function handlePrize(prize: Prize) {
const setFanItemRef = (el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
fanItemRefs.value.push(el)
}
}
const setBlessingCharRef = (el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
blessingCharRefs.value.push(el)
}
}
const clearBlessingAnimation = () => {
blessingTimeline?.kill()
blessingTimeline = null
floatTween?.kill()
floatTween = null
}
const startFloating = () => {
if (!blessingTagRef.value) {
return
}
floatTween?.kill()
floatTween = gsap.to(blessingTagRef.value, {
y: -10,
rotation: 0.8,
duration: 2.6,
repeat: -1,
yoyo: true,
ease: 'sine.inOut',
})
}
const playBlessingAnimation = () => {
const wrap = blessingWrapRef.value
const glow = blessingGlowRef.value
const scene = blessingFanSceneRef.value
const fan = blessingFanRef.value
const slot = blessingTagSlotRef.value
const tag = blessingTagRef.value
const tagTop = blessingTagTopRef.value
const seal = blessingSealRef.value
const caption = blessingCaptionRef.value
const fanItems = [...fanItemRefs.value]
const chars = [...blessingCharRefs.value]
if (
!wrap ||
!glow ||
!scene ||
!fan ||
!slot ||
!tag ||
!tagTop ||
!seal ||
!caption ||
!fanItems.length
) {
return
}
clearBlessingAnimation()
gsap.set(wrap, { transformPerspective: 1400 })
gsap.set(scene, { y: 20, opacity: 0 })
gsap.set(glow, { opacity: 0, scale: 0.78 })
gsap.set(slot, { opacity: 0, scaleX: 0.72, transformOrigin: '50% 50%' })
gsap.set(fan, { y: 34, opacity: 0 })
gsap.set(blessingTagStageRef.value, {
opacity: 0,
y: 26,
filter: 'blur(8px)',
transformOrigin: '50% 100%',
})
gsap.set(fanItems, {
opacity: 0,
y: 78,
rotate: (_index, target) => Number(target.getAttribute('data-angle') || 0) * 0.4,
scale: (_index, target) => Number(target.getAttribute('data-scale') || 1) - 0.08,
transformOrigin: '50% 100%',
})
gsap.set(tag, {
opacity: 0,
y: 356,
scale: 0.94,
rotation: 0,
transformOrigin: '50% 100%',
filter: 'blur(10px)',
})
gsap.set(tagTop, { opacity: 0.65 })
gsap.set(chars, { opacity: 0, y: 10 })
gsap.set(seal, { opacity: 0, y: 8 })
gsap.set(caption, { opacity: 0, y: 20 })
blessingTimeline = gsap.timeline({
defaults: { ease: 'power2.out' },
onComplete: () => {
startFloating()
},
})
blessingTimeline
.to(scene, { opacity: 1, y: 0, duration: 0.45 })
.to(glow, { opacity: 0.95, scale: 1, duration: 0.7, ease: 'sine.out' }, '<')
.to(slot, { opacity: 1, scaleX: 1, duration: 0.52 }, '-=0.3')
.to(fan, { opacity: 1, y: 0, duration: 0.4 }, '<')
.to(
fanItems,
{
opacity: 1,
y: (_index, target) => Number(target.getAttribute('data-y') || 0),
rotate: (_index, target) => Number(target.getAttribute('data-angle') || 0),
scale: (_index, target) => Number(target.getAttribute('data-scale') || 1),
duration: 0.9,
ease: 'back.out(1.08)',
stagger: {
each: 0.055,
from: 'center',
},
},
'+=0.02',
)
.to(
fanItems,
{
x: (_index, target) => Number(target.getAttribute('data-x') || 0) * 0.1,
duration: 0.32,
ease: 'power1.inOut',
stagger: {
each: 0.02,
from: 'edges',
},
yoyo: true,
repeat: 1,
},
'+=0.26',
)
.to(
blessingTagStageRef.value,
{
opacity: 1,
y: 0,
filter: 'blur(0px)',
duration: 0.48,
ease: 'power2.out',
},
'+=0.02',
)
.to(
tag,
{
y: 272,
opacity: 0.5,
scale: 0.97,
filter: 'blur(6px)',
duration: 0.42,
ease: 'power2.out',
},
'<',
)
.to(
tag,
{
y: 44,
opacity: 1,
scale: 1.01,
filter: 'blur(1px)',
duration: 1.08,
ease: 'power3.out',
},
'+=0.1',
)
.to(
tag,
{
y: 18,
rotation: -1.4,
scale: 1.015,
filter: 'blur(0px)',
duration: 0.3,
ease: 'power2.out',
},
'-=0.18',
)
.to(
tag,
{
y: 24,
rotation: 0.4,
scale: 1,
filter: 'blur(0px)',
duration: 0.32,
ease: 'back.out(1.5)',
},
'+=0.02',
)
.to(tagTop, { opacity: 1, duration: 0.22 }, '<')
.to(
chars,
{
opacity: 1,
y: 0,
duration: 0.34,
stagger: 0.05,
},
'-=0.08',
)
.to(
seal,
{
opacity: 1,
y: 0,
duration: 0.28,
},
'-=0.12',
)
.to(
caption,
{
opacity: 1,
y: 0,
duration: 0.42,
},
'-=0.02',
)
}
watch(
() => showResult.value,
async (visible) => {
if (!visible) {
clearBlessingAnimation()
return
}
if (!isThanksPrize.value) {
clearBlessingAnimation()
return
}
await nextTick()
playBlessingAnimation()
},
)
onBeforeUpdate(() => {
fanItemRefs.value = []
blessingCharRefs.value = []
})
onBeforeUnmount(() => {
clearBlessingAnimation()
})
const handlePrizeResult = (prize: LuckWheelResultDto) => {
currentPrize.value = prize
showResult.value = true
}
const testAnimation = () => {
showResult.value = true
currentPrize.value = {
prizeId: null,
prizeImageUrl: '',
prizeName: '谢谢参与',
blessingText: '拒绝精神内耗 本人配享太庙',
coinCost: 0,
isWin: false,
}
}
</script>
<style>
/* 中奖弹层动画 */
.result-enter-active {
animation: fadeIn 0.3s ease;
animation: resultFadeIn 0.42s ease;
}
@keyframes fadeIn {
.result-leave-active {
animation: resultFadeOut 0.24s ease forwards;
}
@keyframes resultFadeIn {
from {
opacity: 0;
transform: scale(1.1);
transform: scale(1.03);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 礼物弹跳 */
@keyframes resultFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.985);
}
}
.animate-pop {
animation: pop 0.8s ease-out;
}
@keyframes pop {
0% {
transform: scale(0.3);
......@@ -161,4 +503,351 @@ function handlePrize(prize: Prize) {
transform: scale(1);
}
}
.blessing-wrap {
position: relative;
width: min(92vw, 520px);
min-height: min(84vh, 680px);
display: flex;
align-items: flex-end;
justify-content: center;
isolation: isolate;
}
.blessing-glow {
position: absolute;
left: 50%;
bottom: 120px;
width: 390px;
height: 390px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(255, 223, 119, 0.44) 0%,
rgba(255, 204, 63, 0.14) 45%,
rgba(255, 204, 63, 0) 76%
);
transform: translateX(-50%);
filter: blur(18px);
z-index: 0;
pointer-events: none;
}
.blessing-fan-scene {
position: relative;
width: min(92vw, 520px);
height: 510px;
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1;
}
.blessing-fan {
position: absolute;
left: 50%;
bottom: 28px;
width: min(92vw, 520px);
height: 408px;
transform: translateX(-50%);
z-index: 1;
}
/* .blessing-tag-slot {
position: absolute;
left: 50%;
bottom: 56px;
width: 138px;
height: 100px;
border-radius: 999px 999px 30px 30px;
background:
linear-gradient(180deg, rgba(107, 68, 0, 0.34), rgba(107, 68, 0, 0.06)),
radial-gradient(circle at 50% 0, rgba(255, 233, 165, 0.42), rgba(255, 233, 165, 0) 74%);
box-shadow:
inset 0 10px 18px rgba(87, 51, 0, 0.18),
0 10px 22px rgba(0, 0, 0, 0.16);
transform: translateX(-50%);
z-index: 2;
} */
.blessing-tag-stage {
position: absolute;
left: 50%;
bottom: 28px;
width: 188px;
height: 550px;
/* overflow: hidden; */
transform: translateX(-50%);
z-index: 2;
}
.blessing-fan-item {
position: absolute;
left: 50%;
bottom: 0;
width: 84px;
height: 450px;
transform-origin: center 100%;
transform: translateX(calc(-50% + var(--x-offset))) translateY(var(--y-offset))
rotate(var(--angle)) scale(var(--scale));
}
.blessing-fan-face {
position: relative;
width: 100%;
height: 100%;
border-radius: 16px 16px 11px 11px;
background:
linear-gradient(
180deg,
rgba(255, 249, 204, 0.76) 0%,
rgba(255, 236, 165, 0.16) 12%,
transparent 18%
),
linear-gradient(180deg, #ffdd68 0%, #f7c529 56%, #ebb116 100%);
border: 1px solid rgba(123, 79, 0, 0.18);
box-shadow:
0 16px 28px rgba(0, 0, 0, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.45),
inset 0 -10px 24px rgba(162, 101, 0, 0.12);
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0 18px;
overflow: hidden;
}
.blessing-fan-face::before {
content: '';
position: absolute;
inset: 9px;
border-radius: 11px;
border: 1px solid rgba(142, 92, 0, 0.14);
pointer-events: none;
}
.blessing-fan-emoji {
position: relative;
z-index: 1;
color: rgba(94, 53, 0, 0.88);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.5px;
}
.blessing-fan-text {
position: relative;
z-index: 1;
margin-top: 20px;
color: rgba(96, 53, 0, 0.68);
font-size: 17px;
font-weight: 600;
letter-spacing: 1px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.blessing-fan-char {
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.18);
}
.blessing-tag {
position: absolute;
left: 50%;
bottom: 0;
width: 120px;
min-height: 470px;
padding: 20px 0 26px;
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.36) 0%, rgba(255, 255, 255, 0) 14%),
linear-gradient(180deg, #ffe783 0%, #ffc93f 48%, #f0b416 100%);
border: 1px solid rgba(118, 74, 0, 0.18);
box-shadow:
0 24px 54px rgba(0, 0, 0, 0.24),
0 12px 36px rgba(255, 205, 63, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.52),
inset 0 -16px 24px rgba(171, 100, 0, 0.12);
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
transform: translateX(-50%);
z-index: 3;
}
.blessing-tag::before {
content: '';
position: absolute;
inset: 10px 9px;
border-radius: 14px;
border: 1px solid rgba(125, 81, 0, 0.16);
pointer-events: none;
}
.blessing-tag::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
top: 12px;
height: 92px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0));
filter: blur(4px);
pointer-events: none;
}
.blessing-tag-top {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.blessing-tag-hole {
width: 13px;
height: 13px;
border-radius: 50%;
background: rgba(116, 72, 0, 0.28);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.14);
}
.blessing-tag-knot {
width: 2px;
height: 26px;
margin-top: 4px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(125, 82, 0, 0.72), rgba(125, 82, 0, 0.2));
}
.blessing-tag-headline {
margin-top: 6px;
color: rgba(120, 69, 0, 0.56);
font-size: 11px;
font-weight: 800;
letter-spacing: 3px;
}
.blessing-tag-divider {
width: 56px;
height: 1px;
margin: 12px 0 18px;
background: linear-gradient(
90deg,
rgba(133, 86, 0, 0),
rgba(133, 86, 0, 0.46),
rgba(133, 86, 0, 0)
);
}
.blessing-text {
color: #603500;
font-size: 26px;
font-weight: 700;
letter-spacing: 2px;
line-height: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
flex: 1;
padding: 0 10px;
}
.blessing-text.is-long-text {
font-size: 23px;
gap: 8px;
}
.blessing-text > span {
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.24);
}
.blessing-tag-seal {
margin-top: 18px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(144, 41, 22, 0.1);
color: rgba(123, 44, 28, 0.68);
font-size: 10px;
font-weight: 800;
letter-spacing: 1.8px;
}
.blessing-caption {
margin-top: 34px;
text-align: center;
}
.blessing-caption-title {
margin: 0;
color: rgba(255, 255, 255, 0.96);
font-size: 34px;
font-weight: 700;
letter-spacing: 3px;
}
.blessing-caption-subtitle {
margin: 12px 0 0;
color: rgba(255, 255, 255, 0.72);
font-size: 15px;
letter-spacing: 1px;
}
@media (max-width: 640px) {
.blessing-wrap {
width: min(94vw, 390px);
min-height: 560px;
}
.blessing-fan-scene {
width: min(94vw, 390px);
height: 434px;
}
.blessing-fan {
width: min(94vw, 390px);
height: 350px;
bottom: 24px;
}
.blessing-fan-item {
width: 70px;
height: 304px;
}
.blessing-tag-stage {
width: 160px;
height: 484px;
bottom: 24px;
}
.blessing-tag {
width: 118px;
min-height: 410px;
}
.blessing-fan-text {
font-size: 14px;
gap: 6px;
}
.blessing-text {
font-size: 23px;
gap: 8px;
}
.blessing-text.is-long-text {
font-size: 20px;
gap: 7px;
}
.blessing-caption-title {
font-size: 28px;
}
}
</style>
......@@ -66,7 +66,10 @@
</p>
<!-- Actions -->
<div class="mt-5 grid grid-cols-2 gap-2.5">
<div
class="mt-5 grid grid-cols-2 gap-2.5"
:class="{ 'grid-cols-1!': !showCancelButton || !showConfirmButton }"
>
<button
v-if="showCancelButton"
type="button"
......
......@@ -71,7 +71,7 @@
</div>
</div>
</div>
<div class="right flex-col gap-3 flex basis-1/4 xl:basis-1/4 min-w-0">
<div class="right flex-col gap-3 basis-1/4 xl:basis-1/4 min-w-0 hidden sm:flex">
<!-- 等级等相关信息 -->
<div
ref="levelContainerRef"
......@@ -350,14 +350,20 @@
</div>
<!-- 大转盘 -->
<div class="lottery-container common-box rounded-lg bg-#F5F0FF">
<div class="flex items-center gap-2 mb-4">
<div
v-if="wheelConfig?.isActivityActive"
class="lottery-container common-box rounded-lg bg-#F5F0FF"
>
<div class="flex items-center gap-2 xl:mb-4">
<div class="w-1 h-4 bg-gradient-to-b from-violet-500 to-purple-500 rounded-full"></div>
<h1 class="text-sm sm:text-base font-bold">大转盘</h1>
</div>
<div class="flex justify-center">
<LuckyWheel />
</div>
<div class="flex items-center justify-center text-sm text-gray-500 xl:mt-4 px-1 truncate">
每次抽奖{{ wheelConfig?.costYaCoin }} YA币
</div>
</div>
</div>
</div>
......@@ -384,6 +390,7 @@ import {
getRecordData,
getUserDailyLotteryInfo,
userJoinLottery,
getWheelConfig,
} from '@/api'
import { TaskTypeEnum, TaskDateLimitTypeText, ArticleTypeEnum } from '@/constants'
import type {
......@@ -392,6 +399,7 @@ import type {
UserAccountDataDto,
UserRecordDataDto,
DailyLotteryInfo,
WheelConfigDto,
} from '@/api'
import { TABS_REF_KEY, levelListOptions } from '@/constants'
import { useScrollTop } from '@/hooks'
......@@ -532,6 +540,11 @@ const handleLottery = async () => {
push.success('参与每日抽奖成功!')
}
/**
* 大转盘
*/
const wheelConfig = ref<WheelConfigDto>(null)
const handleTask = async (item: TaskItemDto) => {
if (item.currentCount === item.limitCount) return
// 先暂时写死
......@@ -584,7 +597,8 @@ const initPage = () => {
getUserAccountData(),
getRecordData(),
getUserDailyLotteryInfo(),
]).then(([r1, r2, r3, r4]) => {
getWheelConfig(),
]).then(([r1, r2, r3, r4, r5]) => {
if (r1.status === 'fulfilled') {
carouselList.value = r1.value.data
}
......@@ -598,6 +612,9 @@ const initPage = () => {
if (r4.status === 'fulfilled') {
lotteryPrizesDetail.value = r4.value.data
}
if (r5.status === 'fulfilled') {
wheelConfig.value = r5.value.data
}
})
}
......@@ -623,6 +640,7 @@ onActivated(async () => {
refreshTaskData(false)
refreshUserAccountData()
getLotteryPrizesDetail()
if (route.fullPath.includes('#levelContainerRef')) {
await handleBackTop()
open.value = true
......
......@@ -66,10 +66,10 @@
</template>
<script lang="tsx" setup>
import { getSelfAuctionRecord, getUserLotteryRecordList } from '@/api'
import { getSelfAuctionRecord, getUserLotteryRecordList, getUserWheelRecordList } from '@/api'
import { usePageSearch } from '@/hooks'
import { ActivityTypeEnum } from '@/constants/enums'
import type { UserLotteryRecordItemDto } from '@/api/dailyLottery/types'
import type { UserLotteryRecordItemDto, UserWheelRecordItemDto } from '@/api'
const EmptyComp = () => (
<div class="flex flex-col items-center justify-center h-64">
......@@ -182,6 +182,56 @@ const activityTypeListOptions = [
),
refresh: () => refresh2(),
},
{
label: '大转盘',
value: ActivityTypeEnum.WHEEL,
component: () => (
<>
{!list3.value.length ? (
<EmptyComp />
) : (
<>
<div class="space-y-4">
<el-table height="500" data={list3.value} stripe border loading={loading3.value}>
<el-table-column prop="prizeName" label="名称">
{({ row }: { row: UserWheelRecordItemDto }) => (
<div>{row.prizeName ?? `谢谢参与(${row.blessingText})`}</div>
)}
</el-table-column>
<el-table-column prop="createdAtStr" label="参与时间" />
<el-table-column prop="isLotteryDone" label="是否中奖">
{({ row }: { row: UserWheelRecordItemDto }) => (
<div>
{row.isWin ? (
<span class="text-green-500"></span>
) : (
<span class="text-red-500"></span>
)}
</div>
)}
</el-table-column>
</el-table>
</div>
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
<div class="pagination-wrapper bg-white rounded-lg shadow-sm border border-gray-100 p-2">
<el-pagination
v-model:current-page={searchParams3.value.current}
v-model:page-size={searchParams3.value.size}
onSizeChange={changePageSize3}
onCurrentChange={goToPage3}
page-sizes={[10, 20, 30, 40]}
layout="prev, pager, next, jumper, total"
total={total3.value}
class="custom-pagination"
/>
</div>
</div>
</>
)}
</>
),
refresh: () => refresh3(),
},
]
const tab = ref(activityTypeListOptions[0]!.value)
......@@ -212,7 +262,17 @@ const {
} = usePageSearch(getUserLotteryRecordList, {
immediate: false,
})
const {
list: list3,
loading: loading3,
searchParams: searchParams3,
total: total3,
refresh: refresh3,
goToPage: goToPage3,
changePageSize: changePageSize3,
} = usePageSearch(getUserWheelRecordList, {
immediate: false,
})
const refresh = () => {
activityTypeListOptions.find((item) => item.value === tab.value)?.refresh?.()
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment