feat: 🎁 新增破冰抽奖功能及 82 人名单
- 新增 src/views/PrizeDraw 抽奖视图及抽奖配置 store - 更新 defaultPersonList 为 82 位真实参与者名单 - 调整主页、路由、i18n 及音乐播放以支持抽奖入口 - 附抽奖需求及实现报告文档 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import useStore from '@/store'
|
||||
import HeaderTitle from './components/HeaderTitle/index.vue'
|
||||
import OptionButton from './components/OptionsButton/index.vue'
|
||||
@@ -11,8 +12,13 @@ import 'vue-toast-notification/dist/theme-sugar.css'
|
||||
const viewModel = useViewModel()
|
||||
const { setDefaultPersonList, tableData, currentStatus, enterLottery, stopLottery, containerRef, startLottery, continueLottery, quitLottery, isInitialDone, titleFont, titleFontSyncGlobal } = viewModel
|
||||
const globalConfig = useStore().globalConfig
|
||||
const router = useRouter()
|
||||
|
||||
const { getTopTitle: topTitle, getTextColor: textColor, getTextSize: textSize, getBackground: homeBackground } = storeToRefs(globalConfig)
|
||||
|
||||
function openPrizeDraw() {
|
||||
window.open('/log-lottery/prize-draw', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,6 +45,16 @@ const { getTopTitle: topTitle, getTextColor: textColor, getTextSize: textSize, g
|
||||
</div>
|
||||
<StarsBackground :home-background="homeBackground" />
|
||||
<PrizeList class="absolute left-0 top-32" />
|
||||
|
||||
<!-- 右下角进入抽奖按钮 -->
|
||||
<div v-if="isInitialDone" class="fixed bottom-8 right-8 z-[9999] pointer-events-auto">
|
||||
<button
|
||||
class="cursor-pointer btn btn-outline btn-primary btn-lg shadow-lg hover:shadow-xl transition-all px-6 py-4"
|
||||
@click="openPrizeDraw"
|
||||
>
|
||||
🎁 进入活动抽奖
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -8,9 +8,7 @@ import { CSS3DObject, CSS3DRenderer } from 'three-css3d'
|
||||
import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import dongSound from '@/assets/audio/end.mp3'
|
||||
import enterAudio from '@/assets/audio/enter.wav'
|
||||
import worldCupAudio from '@/assets/audio/worldcup.mp3'
|
||||
import worldCupAudio from '@/assets/audio/worldcup.mp3?url'
|
||||
import { CONFETTI_FIRE_MAX_COUNT, SINGLE_TIME_MAX_PERSON_COUNT } from '@/constant/config'
|
||||
import { useElementPosition, useElementStyle } from '@/hooks/useElement'
|
||||
import i18n from '@/locales/i18n'
|
||||
@@ -357,9 +355,20 @@ export function useViewModel() {
|
||||
lotteryMusic.value = new Audio(worldCupAudio)
|
||||
lotteryMusic.value.loop = true
|
||||
lotteryMusic.value.volume = 0.7
|
||||
|
||||
// 添加音频加载事件监听
|
||||
lotteryMusic.value.addEventListener('canplaythrough', () => {
|
||||
console.log('音频加载完成')
|
||||
})
|
||||
|
||||
lotteryMusic.value.addEventListener('error', (e) => {
|
||||
console.error('音频加载错误:', e)
|
||||
console.error('音频路径:', worldCupAudio)
|
||||
})
|
||||
|
||||
lotteryMusic.value.play().catch((error) => {
|
||||
console.error('播放抽奖音乐失败:', error)
|
||||
console.error('音频路径:', worldCupAudio)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -380,45 +389,7 @@ export function useViewModel() {
|
||||
* @description: 播放结束音效
|
||||
*/
|
||||
function playEndSound() {
|
||||
if (!isPlayWinMusic.value) {
|
||||
return
|
||||
}
|
||||
console.log('准备播放结束音效', dongSound)
|
||||
|
||||
// 清理已结束的音频
|
||||
playingAudios.value = playingAudios.value.filter(audio => !audio.ended)
|
||||
|
||||
try {
|
||||
const endSound = new Audio(dongSound)
|
||||
endSound.volume = 1.0
|
||||
|
||||
// 简化播放逻辑
|
||||
const playPromise = endSound.play()
|
||||
|
||||
if (playPromise) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
console.log('结束音效播放成功')
|
||||
playingAudios.value.push(endSound)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('播放失败:', err.name, err.message)
|
||||
if (err.name === 'NotAllowedError') {
|
||||
console.warn('自动播放被阻止,需用户交互后播放')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
endSound.onended = () => {
|
||||
console.log('结束音效播放完成')
|
||||
const index = playingAudios.value.indexOf(endSound)
|
||||
if (index > -1)
|
||||
playingAudios.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('创建音频对象失败:', error)
|
||||
}
|
||||
// 已移除结束音效
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,7 +441,7 @@ export function useViewModel() {
|
||||
if (patternList.value.length) {
|
||||
for (let i = 0; i < patternList.value.length; i++) {
|
||||
if (i < rowCount.value * 7) {
|
||||
objects.value[patternList.value[i] - 1].element.style.backgroundColor = rgba(cardColor.value, Math.random() * 0.5 + 0.25)
|
||||
objects.value[patternList.value[i] - 1].element.style.backgroundColor = rgba(patternColor.value, Math.random() * 0.2 + 0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,47 +601,7 @@ export function useViewModel() {
|
||||
}
|
||||
// 播放音频,中将卡片越多audio对象越多,声音越大
|
||||
function playWinMusic() {
|
||||
if (!isPlayWinMusic.value) {
|
||||
return
|
||||
}
|
||||
// 清理已结束的音频
|
||||
playingAudios.value = playingAudios.value.filter(audio => !audio.ended && !audio.paused)
|
||||
|
||||
if (playingAudios.value.length > maxAudioLimit) {
|
||||
console.log('音频播放数量已达到上限,请勿重复播放')
|
||||
return
|
||||
}
|
||||
|
||||
const enterNewAudio = new Audio(enterAudio)
|
||||
enterNewAudio.volume = 0.8
|
||||
|
||||
playingAudios.value.push(enterNewAudio)
|
||||
enterNewAudio.play()
|
||||
.then(() => {
|
||||
// 当音频播放结束后,从数组中移除
|
||||
enterNewAudio.onended = () => {
|
||||
const index = playingAudios.value.indexOf(enterNewAudio)
|
||||
if (index > -1) {
|
||||
playingAudios.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('播放音频失败:', error)
|
||||
// 如果播放失败,也从数组中移除
|
||||
const index = playingAudios.value.indexOf(enterNewAudio)
|
||||
if (index > -1) {
|
||||
playingAudios.value.splice(index, 1)
|
||||
}
|
||||
})
|
||||
|
||||
// 播放错误时从数组中移除
|
||||
enterNewAudio.onerror = () => {
|
||||
const index = playingAudios.value.indexOf(enterNewAudio)
|
||||
if (index > -1) {
|
||||
playingAudios.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
// 已移除中奖音效
|
||||
}
|
||||
/**
|
||||
* @description: 继续,意味着这抽奖作数,计入数据库
|
||||
|
||||
548
src/views/PrizeDraw/components/DrawArea.vue
Normal file
548
src/views/PrizeDraw/components/DrawArea.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<div class="draw-area">
|
||||
<!-- 初始状态 -->
|
||||
<div v-if="!lastResult && !isDrawing && !hasStarted" class="draw-initial">
|
||||
<div class="draw-icon">🎁</div>
|
||||
<h2 class="draw-title">准备抽奖</h2>
|
||||
<p class="draw-subtitle">点击下方按钮抽取您的奖品</p>
|
||||
</div>
|
||||
|
||||
<!-- 奖品卡片墙 - 使用原页面卡片样式 -->
|
||||
<div v-else class="prize-wall-container">
|
||||
<div class="prize-wall">
|
||||
<div
|
||||
v-for="(prize, index) in prizeCards"
|
||||
:key="index"
|
||||
class="element-card"
|
||||
:class="{
|
||||
'card-drawn': prize.isDrawn,
|
||||
'card-highlight': isDrawing && index === highlightIndex,
|
||||
'card-winner': !isDrawing && lastResult && index === winnerCardIndex
|
||||
}"
|
||||
:style="getCardStyle(prize, index)"
|
||||
>
|
||||
<div class="card-uid">{{ prize.isDrawn ? prize.name.substring(0, 4) : '?' }}</div>
|
||||
<div class="card-name">{{ prize.isDrawn ? getPrizeIcon(prize.name) : '?' }}</div>
|
||||
<div class="card-info">{{ prize.isDrawn ? prize.description.substring(0, 8) : '神秘奖品' }}</div>
|
||||
<img v-if="prize.isDrawn" class="card-avatar" :src="getPrizeImage(prize.name)" alt="">
|
||||
<div v-else class="card-mystery">?</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas ref="confettiCanvas" class="confetti-canvas" />
|
||||
</div>
|
||||
|
||||
<!-- 抽奖按钮 -->
|
||||
<div class="draw-actions">
|
||||
<button
|
||||
v-if="!isCompleted"
|
||||
class="btn-draw"
|
||||
:disabled="isDrawing"
|
||||
@click="handleDraw"
|
||||
>
|
||||
<span v-if="!hasStarted">开始抽奖</span>
|
||||
<span v-else>继续抽奖</span>
|
||||
</button>
|
||||
<div v-else class="completed-message">
|
||||
<div class="completed-icon">🎉</div>
|
||||
<h3>抽奖已完成!</h3>
|
||||
<p>所有88位员工都已抽到奖品</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import confetti from 'canvas-confetti'
|
||||
import type { DrawResult, PrizeConfig } from '@/store/prizeDrawConfig'
|
||||
import { usePrizeDrawStore } from '@/store/prizeDrawConfig'
|
||||
import { rgba } from '@/utils/color'
|
||||
|
||||
const props = defineProps<{
|
||||
isDrawing: boolean
|
||||
isCompleted: boolean
|
||||
lastResult?: DrawResult
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
draw: []
|
||||
}>()
|
||||
|
||||
const store = usePrizeDrawStore()
|
||||
const confettiCanvas = ref<HTMLCanvasElement>()
|
||||
const highlightIndex = ref(0)
|
||||
const hasStarted = ref(false)
|
||||
const winnerCardIndex = ref(-1)
|
||||
|
||||
// 初始化时的随机顺序(保持不变)
|
||||
const initialCardOrder = ref<Array<PrizeConfig & { isDrawn: boolean, uniqueId: string }>>([])
|
||||
|
||||
onMounted(() => {
|
||||
// 只在第一次初始化时生成随机顺序
|
||||
if (initialCardOrder.value.length === 0) {
|
||||
const cards: Array<PrizeConfig & { isDrawn: boolean, uniqueId: string }> = []
|
||||
|
||||
store.prizeConfigs.forEach((config) => {
|
||||
for (let i = 0; i < config.totalCount; i++) {
|
||||
cards.push({
|
||||
...config,
|
||||
isDrawn: false,
|
||||
uniqueId: `${config.id}-${i}-${Math.random()}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 使用 Fisher-Yates 洗牌算法随机打乱
|
||||
for (let i = cards.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[cards[i], cards[j]] = [cards[j], cards[i]]
|
||||
}
|
||||
|
||||
initialCardOrder.value = cards
|
||||
}
|
||||
})
|
||||
|
||||
// 生成88个奖品卡片(包含已抽和未抽状态)
|
||||
const prizeCards = computed(() => {
|
||||
if (initialCardOrder.value.length === 0)
|
||||
return []
|
||||
|
||||
// 根据抽奖结果更新卡片状态
|
||||
return initialCardOrder.value.map((card) => {
|
||||
// 检查这个奖品是否已被抽中
|
||||
const config = store.prizeConfigs.find(c => c.id === card.id)
|
||||
if (!config)
|
||||
return card
|
||||
|
||||
const drawnCount = config.totalCount - config.remainingCount
|
||||
|
||||
// 计算这张卡片在同类奖品中的序号
|
||||
const sameTypeCards = initialCardOrder.value.filter(c => c.id === card.id)
|
||||
const cardIndex = sameTypeCards.findIndex(c => c.uniqueId === card.uniqueId)
|
||||
|
||||
// 如果这张卡片的序号小于已抽数量,则标记为已抽
|
||||
return {
|
||||
...card,
|
||||
isDrawn: cardIndex < drawnCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 获取卡片样式(模仿原页面)
|
||||
function getCardStyle(prize: PrizeConfig & { isDrawn: boolean }, index: number) {
|
||||
const baseColor = prize.color
|
||||
const isHighlight = props.isDrawing && index === highlightIndex.value
|
||||
const isWinner = !props.isDrawing && props.lastResult && index === winnerCardIndex.value
|
||||
const isDrawn = prize.isDrawn
|
||||
|
||||
let bgColor = rgba(baseColor, Math.random() * 0.5 + 0.25)
|
||||
let borderColor = rgba(baseColor, 0.25)
|
||||
let boxShadow = `0 0 12px ${rgba(baseColor, 0.5)}`
|
||||
|
||||
if (isDrawn) {
|
||||
bgColor = rgba('#95a5a6', 0.3)
|
||||
borderColor = rgba('#95a5a6', 0.2)
|
||||
boxShadow = `0 0 8px ${rgba('#95a5a6', 0.3)}`
|
||||
}
|
||||
|
||||
if (isHighlight) {
|
||||
borderColor = rgba('#f39c12', 0.9)
|
||||
boxShadow = `0 0 30px ${rgba('#f39c12', 0.8)}, 0 0 60px ${rgba('#f39c12', 0.4)}`
|
||||
}
|
||||
|
||||
if (isWinner) {
|
||||
bgColor = rgba(baseColor, 0.9)
|
||||
borderColor = rgba('#27ae60', 0.9)
|
||||
boxShadow = `0 0 40px ${rgba('#27ae60', 0.8)}, 0 0 80px ${rgba('#27ae60', 0.4)}`
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
border: `1px solid ${borderColor}`,
|
||||
boxShadow,
|
||||
}
|
||||
}
|
||||
|
||||
function getPrizeIcon(name: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'快乐通勤奖': '⏰',
|
||||
'跑马场自由日': '🏠',
|
||||
'前途光明奖': '💼',
|
||||
'现金红包500元': '💰',
|
||||
'现金红包300元': '💵',
|
||||
'现金红包200元': '💴',
|
||||
'现金红包100元': '💸',
|
||||
}
|
||||
return iconMap[name] || '🎁'
|
||||
}
|
||||
|
||||
function getPrizeImage(name: string): string {
|
||||
// 返回默认图片或根据奖品类型返回不同图片
|
||||
return 'https://img1.baidu.com/it/u=2165937980,813753762&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
|
||||
}
|
||||
|
||||
// 监听抽奖状态
|
||||
watch(() => props.isDrawing, (isDrawing) => {
|
||||
if (isDrawing) {
|
||||
hasStarted.value = true
|
||||
startScrollAnimation()
|
||||
}
|
||||
else {
|
||||
stopScrollAnimation()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听结果变化
|
||||
watch(() => props.lastResult, (result) => {
|
||||
if (result) {
|
||||
nextTick(() => {
|
||||
fireConfetti()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let scrollInterval: number | null = null
|
||||
|
||||
function startScrollAnimation() {
|
||||
// 随机滚动时长 5-8秒
|
||||
const duration = 5000 + Math.random() * 3000
|
||||
const startTime = Date.now()
|
||||
let currentIndex = 0
|
||||
|
||||
// 只在未抽的卡片中滚动
|
||||
const availableIndices = prizeCards.value
|
||||
.map((card, index) => ({ card, index }))
|
||||
.filter(item => !item.card.isDrawn)
|
||||
.map(item => item.index)
|
||||
|
||||
if (availableIndices.length === 0)
|
||||
return
|
||||
|
||||
scrollInterval = window.setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = elapsed / duration
|
||||
|
||||
if (progress >= 1) {
|
||||
// 滚动结束,停在最终结果
|
||||
if (scrollInterval) {
|
||||
clearInterval(scrollInterval)
|
||||
scrollInterval = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 速度逐渐减慢
|
||||
const speed = progress < 0.7 ? 50 : 50 + (progress - 0.7) * 300
|
||||
|
||||
currentIndex = (currentIndex + 1) % availableIndices.length
|
||||
highlightIndex.value = availableIndices[currentIndex]
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function stopScrollAnimation() {
|
||||
if (scrollInterval) {
|
||||
clearInterval(scrollInterval)
|
||||
scrollInterval = null
|
||||
}
|
||||
|
||||
// 找到最终中奖的卡片索引
|
||||
if (props.lastResult) {
|
||||
// 找到第一个未抽且匹配中奖奖品ID的卡片
|
||||
const winnerIndex = prizeCards.value.findIndex(
|
||||
card => !card.isDrawn && card.id === props.lastResult?.prizeId,
|
||||
)
|
||||
if (winnerIndex !== -1) {
|
||||
highlightIndex.value = winnerIndex
|
||||
winnerCardIndex.value = winnerIndex
|
||||
|
||||
// 标记这张卡片为已抽
|
||||
if (initialCardOrder.value[winnerIndex]) {
|
||||
initialCardOrder.value[winnerIndex].isDrawn = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireConfetti() {
|
||||
if (!confettiCanvas.value)
|
||||
return
|
||||
|
||||
const myConfetti = confetti.create(confettiCanvas.value, {
|
||||
resize: true,
|
||||
useWorker: true,
|
||||
})
|
||||
|
||||
const duration = 3000
|
||||
const animationEnd = Date.now() + duration
|
||||
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const timeLeft = animationEnd - Date.now()
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration)
|
||||
|
||||
myConfetti({
|
||||
particleCount,
|
||||
startVelocity: 30,
|
||||
spread: randomInRange(50, 70),
|
||||
origin: {
|
||||
x: randomInRange(0.1, 0.9),
|
||||
y: Math.random() - 0.2,
|
||||
},
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F'],
|
||||
})
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function handleDraw() {
|
||||
emit('draw')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.draw-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.confetti-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.draw-initial {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.draw-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.draw-title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin: 0 0 12px 0;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.draw-subtitle {
|
||||
font-size: 18px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.prize-wall-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prize-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.element-card {
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.card-drawn {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
|
||||
&.card-highlight {
|
||||
transform: scale(1.1);
|
||||
z-index: 10;
|
||||
animation: pulse 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.card-winner {
|
||||
transform: scale(1.15);
|
||||
z-index: 20;
|
||||
animation: winner-glow 1s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes winner-glow {
|
||||
0%, 100% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.card-uid {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-mystery {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.draw-actions {
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btn-draw {
|
||||
padding: 16px 48px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.completed-message {
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.completed-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: celebrate 1s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes celebrate {
|
||||
0%, 100% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
.completed-message h3 {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin: 0 0 12px 0;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.completed-message p {
|
||||
font-size: 18px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
237
src/views/PrizeDraw/components/DrawHistory.vue
Normal file
237
src/views/PrizeDraw/components/DrawHistory.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="draw-history">
|
||||
<div v-if="results.length === 0" class="history-empty">
|
||||
<div class="empty-icon">📝</div>
|
||||
<p>暂无抽奖记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<div class="history-actions">
|
||||
<button
|
||||
class="btn-undo"
|
||||
:disabled="results.length === 0"
|
||||
@click="handleUndo"
|
||||
>
|
||||
↶ 撤销最后一次
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="history-scroll">
|
||||
<div
|
||||
v-for="result in results"
|
||||
:key="result.id"
|
||||
class="history-item"
|
||||
>
|
||||
<div class="history-badge">
|
||||
<span class="history-index">#{{ result.drawIndex }}</span>
|
||||
</div>
|
||||
<div class="history-content">
|
||||
<div class="history-prize">
|
||||
<span class="prize-name">{{ result.prizeName }}</span>
|
||||
<span class="prize-desc">{{ result.prizeDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-time">
|
||||
{{ formatTime(result.drawTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DrawResult } from '@/store/prizeDrawConfig'
|
||||
|
||||
defineProps<{
|
||||
results: DrawResult[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
undo: []
|
||||
}>()
|
||||
|
||||
function handleUndo() {
|
||||
emit('undo')
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.draw-history {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.history-empty p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-actions {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-undo {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
background: #f39c12;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e67e22;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.history-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
transition: all 0.2s ease;
|
||||
animation: slideIn 0.3s ease;
|
||||
border: 1px solid #ecf0f1;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: #ecf0f1;
|
||||
transform: translateX(-4px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border: 2px solid #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.history-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 12px;
|
||||
background: #34495e;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.history-index {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.history-person {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.person-id {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.person-name {
|
||||
font-size: 11px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.history-arrow {
|
||||
font-size: 16px;
|
||||
color: #3498db;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.history-prize {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.prize-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 10px;
|
||||
color: #95a5a6;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
183
src/views/PrizeDraw/components/PrizeCard.vue
Normal file
183
src/views/PrizeDraw/components/PrizeCard.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div
|
||||
class="prize-card"
|
||||
:class="{ 'prize-card--empty': prize.remainingCount === 0 }"
|
||||
>
|
||||
<div v-if="showDetails" class="prize-content-detailed">
|
||||
<div class="prize-icon">
|
||||
{{ getPrizeIcon(prize.name) }}
|
||||
</div>
|
||||
<div class="prize-info">
|
||||
<div class="prize-header">
|
||||
<h3 class="prize-name">{{ prize.name }}</h3>
|
||||
<div class="prize-count" :style="{ color: prize.remainingCount > 0 ? prize.color : '#95a5a6' }">
|
||||
{{ prize.remainingCount }}/{{ prize.totalCount }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="prize-description">{{ prize.description }}</p>
|
||||
<div class="prize-progress">
|
||||
<div
|
||||
class="prize-progress-bar"
|
||||
:style="{
|
||||
width: `${(prize.remainingCount / prize.totalCount) * 100}%`,
|
||||
backgroundColor: prize.color
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="prize-content-simple">
|
||||
<div class="mystery-box">
|
||||
<div class="mystery-icon">?</div>
|
||||
</div>
|
||||
<div class="prize-count-simple" :style="{ color: prize.remainingCount > 0 ? prize.color : '#95a5a6' }">
|
||||
{{ prize.remainingCount }}/{{ prize.totalCount }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="prize.remainingCount === 0" class="prize-empty-badge">
|
||||
已抽完
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PrizeConfig } from '@/store/prizeDrawConfig'
|
||||
|
||||
defineProps<{
|
||||
prize: PrizeConfig
|
||||
showDetails: boolean
|
||||
}>()
|
||||
|
||||
function getPrizeIcon(name: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'快乐通勤奖': '⏰',
|
||||
'跑马场自由日': '🏠',
|
||||
'前途光明奖': '💼',
|
||||
'现金红包500元': '💰',
|
||||
'现金红包300元': '💵',
|
||||
'现金红包200元': '💴',
|
||||
'现金红包100元': '💸',
|
||||
}
|
||||
return iconMap[name] || '🎁'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.prize-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
border: 2px solid #ecf0f1;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&--empty {
|
||||
opacity: 0.5;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.prize-content-detailed {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.prize-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prize-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prize-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prize-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.prize-count {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prize-description {
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.prize-progress {
|
||||
height: 4px;
|
||||
background: #ecf0f1;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prize-progress-bar {
|
||||
height: 100%;
|
||||
transition: width 0.5s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.prize-content-simple {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mystery-box {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mystery-icon {
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.prize-count-simple {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.prize-empty-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
832
src/views/PrizeDraw/index.vue
Normal file
832
src/views/PrizeDraw/index.vue
Normal file
@@ -0,0 +1,832 @@
|
||||
<template>
|
||||
<div class="prize-draw-page">
|
||||
<!-- 星空背景 -->
|
||||
<StarsBackground :home-background="homeBackground" />
|
||||
|
||||
<!-- 顶部标题 -->
|
||||
<div class="header-wrapper">
|
||||
<h2
|
||||
class="page-title"
|
||||
:class="{ 'animate-pulse bg-linear-to-r from-primary via-secondary to-accent bg-clip-text text-transparent': !isTextColor }"
|
||||
:style="titleStyle"
|
||||
>
|
||||
{{ topTitle }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- 抽奖进度和重置按钮 -->
|
||||
<div class="prize-info">
|
||||
<div class="progress-text" :style="{ color: textColor }">
|
||||
已抽奖:{{ prizeDrawStore.currentDrawIndex }} / 88
|
||||
</div>
|
||||
<button class="reset-button" @click="handleReset">
|
||||
重置抽奖
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 3D容器 -->
|
||||
<div id="prize-container" ref="containerRef" class="container-3d" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<OptionButton
|
||||
:current-status="currentStatus"
|
||||
:table-data="tableData"
|
||||
:enter-lottery="enterLottery"
|
||||
:start-lottery="startLottery"
|
||||
:stop-lottery="stopLottery"
|
||||
:continue-lottery="continueLottery"
|
||||
:quit-lottery="quitLottery"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { PerspectiveCamera, Scene } from 'three'
|
||||
import { CSS3DObject, CSS3DRenderer } from 'three-css3d'
|
||||
import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'
|
||||
import * as TWEEN from '@tweenjs/tween.js'
|
||||
import useStore from '@/store'
|
||||
import { usePrizeDrawStore } from '@/store/prizeDrawConfig'
|
||||
import type { DrawResult } from '@/store/prizeDrawConfig'
|
||||
import StarsBackground from '@/views/Home/components/StarsBackground/index.vue'
|
||||
import OptionButton from '@/views/Home/components/OptionsButton/index.vue'
|
||||
import { useElementStyle, useElementPosition } from '@/hooks/useElement'
|
||||
import { createTableVertices, createSphereVertices, confettiFire, initTableData, getRandomElements } from '@/views/Home/utils'
|
||||
import { rgba, rgbToHex } from '@/utils/color'
|
||||
import { selectCard } from '@/utils'
|
||||
import { LotteryStatus } from '@/views/Home/type'
|
||||
import type { IPersonConfig } from '@/types/storeType'
|
||||
|
||||
// Store
|
||||
const { personConfig, globalConfig, prizeConfig } = useStore()
|
||||
const prizeDrawStore = usePrizeDrawStore()
|
||||
const {
|
||||
getAllPersonList: allPersonList,
|
||||
getNotPersonList: notPersonList,
|
||||
getNotThisPrizePersonList: notThisPrizePersonList,
|
||||
} = storeToRefs(personConfig)
|
||||
const { getCurrentPrize: currentPrize } = storeToRefs(prizeConfig)
|
||||
const {
|
||||
getBackground: homeBackground,
|
||||
getCardColor: cardColor,
|
||||
getPatterColor: patternColor,
|
||||
getPatternList: patternList,
|
||||
getTextColor: textColor,
|
||||
getLuckyColor: luckyColor,
|
||||
getCardSize: cardSize,
|
||||
getTextSize: textSize,
|
||||
getRowCount: rowCount,
|
||||
getTitleFont: titleFont,
|
||||
getTitleFontSyncGlobal: titleFontSyncGlobal,
|
||||
getTopTitle: topTitle,
|
||||
} = storeToRefs(globalConfig)
|
||||
|
||||
// 标题样式计算
|
||||
const isTextColor = computed(() => {
|
||||
return rgbToHex(textColor.value) !== '#00000000'
|
||||
})
|
||||
|
||||
const titleStyle = computed(() => {
|
||||
const style: any = {
|
||||
fontSize: `${textSize.value * 1.5}px`,
|
||||
}
|
||||
if (!titleFontSyncGlobal.value) {
|
||||
style.fontFamily = titleFont.value
|
||||
}
|
||||
if (isTextColor.value) {
|
||||
style.color = textColor.value
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
// Three.js 相关
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const scene = ref<Scene>()
|
||||
const camera = ref<PerspectiveCamera>()
|
||||
const renderer = ref<CSS3DRenderer>()
|
||||
const controls = ref<TrackballControls>()
|
||||
const objects = ref<any[]>([])
|
||||
const targets: any = {
|
||||
table: [],
|
||||
sphere: [],
|
||||
}
|
||||
const animationFrameId = ref<number>()
|
||||
|
||||
// 页面状态
|
||||
const currentStatus = ref<LotteryStatus>(LotteryStatus.init)
|
||||
const canOperate = ref(true)
|
||||
const isInitialDone = ref(false)
|
||||
const tableData = ref<IPersonConfig[]>([])
|
||||
const luckyTargets = ref<IPersonConfig[]>([])
|
||||
const luckyCardList = ref<number[]>([])
|
||||
const luckyCount = ref(1) // 每次抽1个
|
||||
const personPool = ref<IPersonConfig[]>([])
|
||||
const intervalTimer = ref<any>(null)
|
||||
const currentDrawResult = ref<DrawResult | null>(null) // 保存当前抽奖结果
|
||||
|
||||
// 初始化 Three.js
|
||||
function initThreeJs() {
|
||||
console.log('初始化 Three.js 场景...')
|
||||
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
|
||||
scene.value = new Scene()
|
||||
camera.value = new PerspectiveCamera(40, width / height, 1, 10000)
|
||||
camera.value.position.z = 3000
|
||||
|
||||
renderer.value = new CSS3DRenderer()
|
||||
renderer.value.setSize(width, height * 0.9)
|
||||
renderer.value.domElement.style.position = 'absolute'
|
||||
renderer.value.domElement.style.paddingTop = '50px'
|
||||
renderer.value.domElement.style.top = '50%'
|
||||
renderer.value.domElement.style.left = '50%'
|
||||
renderer.value.domElement.style.transform = 'translate(-50%, -50%)'
|
||||
containerRef.value!.appendChild(renderer.value.domElement)
|
||||
|
||||
controls.value = new TrackballControls(camera.value, renderer.value.domElement)
|
||||
controls.value.minDistance = 500
|
||||
controls.value.maxDistance = 6000
|
||||
controls.value.addEventListener('change', render)
|
||||
|
||||
console.log('创建卡片...')
|
||||
|
||||
// 创建卡片
|
||||
for (let i = 0; i < tableData.value.length; i++) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'element-card'
|
||||
|
||||
const number = document.createElement('div')
|
||||
number.className = 'card-id'
|
||||
number.textContent = tableData.value[i].uid
|
||||
element.appendChild(number)
|
||||
|
||||
const symbol = document.createElement('div')
|
||||
symbol.className = 'card-name'
|
||||
symbol.textContent = tableData.value[i].name
|
||||
element.appendChild(symbol)
|
||||
|
||||
const details = document.createElement('div')
|
||||
details.className = 'card-detail'
|
||||
details.innerHTML = `${tableData.value[i].department}<br/>${tableData.value[i].identity}`
|
||||
element.appendChild(details)
|
||||
|
||||
// 第4个子元素:avatar(空div,因为不显示头像)
|
||||
const avatarEmpty = document.createElement('div')
|
||||
avatarEmpty.style.display = 'none'
|
||||
element.appendChild(avatarEmpty)
|
||||
|
||||
const styledElement = useElementStyle({
|
||||
element,
|
||||
person: tableData.value[i],
|
||||
index: i,
|
||||
patternList: patternList.value,
|
||||
patternColor: patternColor.value,
|
||||
cardColor: cardColor.value,
|
||||
cardSize: { width: cardSize.value.width, height: cardSize.value.height },
|
||||
scale: 1,
|
||||
textSize: textSize.value,
|
||||
mod: 'default',
|
||||
})
|
||||
|
||||
const objectCSS = new CSS3DObject(styledElement)
|
||||
objectCSS.position.x = Math.random() * 4000 - 2000
|
||||
objectCSS.position.y = Math.random() * 4000 - 2000
|
||||
objectCSS.position.z = Math.random() * 4000 - 2000
|
||||
|
||||
scene.value.add(objectCSS)
|
||||
objects.value.push(objectCSS)
|
||||
}
|
||||
|
||||
// 创建目标位置
|
||||
targets.table = createTableVertices({
|
||||
tableData: tableData.value,
|
||||
rowCount: rowCount.value,
|
||||
cardSize: { width: cardSize.value.width, height: cardSize.value.height },
|
||||
})
|
||||
|
||||
targets.sphere = createSphereVertices({ objectsLength: objects.value.length })
|
||||
|
||||
window.addEventListener('resize', onWindowResize, false)
|
||||
|
||||
// 初始聚合到表格
|
||||
transform(targets.table, 1000)
|
||||
|
||||
// 开始动画循环
|
||||
animation()
|
||||
|
||||
isInitialDone.value = true
|
||||
console.log('Three.js 初始化完成')
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (renderer.value && scene.value && camera.value) {
|
||||
renderer.value.render(scene.value, camera.value)
|
||||
}
|
||||
}
|
||||
|
||||
function animation() {
|
||||
animationFrameId.value = requestAnimationFrame(animation)
|
||||
TWEEN.update()
|
||||
controls.value?.update()
|
||||
}
|
||||
|
||||
function transform(targetPositions: any[], duration: number) {
|
||||
TWEEN.removeAll()
|
||||
|
||||
for (let i = 0; i < objects.value.length; i++) {
|
||||
const object = objects.value[i]
|
||||
const target = targetPositions[i]
|
||||
|
||||
new TWEEN.Tween(object.position)
|
||||
.to({
|
||||
x: target.position.x,
|
||||
y: target.position.y,
|
||||
z: target.position.z,
|
||||
}, Math.random() * duration + duration)
|
||||
.easing(TWEEN.Easing.Exponential.InOut)
|
||||
.start()
|
||||
|
||||
new TWEEN.Tween(object.rotation)
|
||||
.to({
|
||||
x: target.rotation.x,
|
||||
y: target.rotation.y,
|
||||
z: target.rotation.z,
|
||||
}, Math.random() * duration + duration)
|
||||
.easing(TWEEN.Easing.Exponential.InOut)
|
||||
.start()
|
||||
}
|
||||
|
||||
new TWEEN.Tween({})
|
||||
.to({}, duration * 2)
|
||||
.onUpdate(render)
|
||||
.start()
|
||||
}
|
||||
|
||||
function rollBall(rotateY: number, duration: number) {
|
||||
TWEEN.removeAll()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!scene.value) {
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
|
||||
scene.value.rotation.y = 0
|
||||
const ballRotationY = Math.PI * rotateY * 1000
|
||||
|
||||
new TWEEN.Tween(scene.value.rotation)
|
||||
.to({ x: 0, y: ballRotationY, z: 0 }, duration * 1000)
|
||||
.onUpdate(render)
|
||||
.start()
|
||||
.onStop(() => resolve(''))
|
||||
.onComplete(() => resolve(''))
|
||||
})
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
if (!camera.value || !renderer.value)
|
||||
return
|
||||
|
||||
camera.value.aspect = window.innerWidth / window.innerHeight
|
||||
camera.value.updateProjectionMatrix()
|
||||
renderer.value.setSize(window.innerWidth, window.innerHeight)
|
||||
render()
|
||||
}
|
||||
|
||||
// 抽奖流程
|
||||
async function enterLottery() {
|
||||
if (!canOperate.value)
|
||||
return
|
||||
|
||||
canOperate.value = false
|
||||
await transform(targets.sphere, 1000)
|
||||
currentStatus.value = LotteryStatus.ready
|
||||
canOperate.value = true
|
||||
}
|
||||
|
||||
function startLottery() {
|
||||
if (!canOperate.value)
|
||||
return
|
||||
|
||||
console.log('开始抽奖')
|
||||
|
||||
// 检查是否还有奖品
|
||||
if (prizeDrawStore.prizePool.length === 0) {
|
||||
console.log('所有奖品已抽完')
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 prizeDrawStore 执行抽奖
|
||||
prizeDrawStore.executeDraw().then((result: DrawResult | null) => {
|
||||
if (!result) {
|
||||
console.log('抽奖失败或已完成')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('抽奖结果:', result)
|
||||
|
||||
// 保存抽奖结果
|
||||
currentDrawResult.value = result
|
||||
|
||||
// 随机选择一张卡片来展示中奖奖品
|
||||
const randomPerson = tableData.value[Math.floor(Math.random() * tableData.value.length)]
|
||||
luckyTargets.value = [randomPerson]
|
||||
|
||||
console.log('展示卡片:', randomPerson.name, '中奖:', result.prizeName)
|
||||
|
||||
currentStatus.value = LotteryStatus.running
|
||||
rollBall(10, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
async function stopLottery() {
|
||||
if (!canOperate.value)
|
||||
return
|
||||
|
||||
console.log('停止抽奖,展示结果')
|
||||
console.log('中奖结果:', currentDrawResult.value)
|
||||
|
||||
if (!currentDrawResult.value) {
|
||||
console.error('没有抽奖结果')
|
||||
return
|
||||
}
|
||||
|
||||
canOperate.value = false
|
||||
|
||||
// 停止旋转并等待完成
|
||||
TWEEN.removeAll()
|
||||
|
||||
// 立即将场景旋转归零
|
||||
if (scene.value) {
|
||||
scene.value.rotation.y = 0
|
||||
scene.value.rotation.x = 0
|
||||
scene.value.rotation.z = 0
|
||||
}
|
||||
|
||||
// 重置相机和控制器到初始位置
|
||||
if (camera.value && controls.value) {
|
||||
camera.value.position.set(0, 0, 3000)
|
||||
camera.value.lookAt(0, 0, 0)
|
||||
controls.value.target.set(0, 0, 0)
|
||||
controls.value.update()
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
// 等待一小段时间确保场景稳定
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
const windowSize = { width: window.innerWidth, height: window.innerHeight }
|
||||
|
||||
luckyTargets.value.forEach((person: IPersonConfig, index: number) => {
|
||||
const cardIndex = selectCard(luckyCardList.value, tableData.value.length, person.id)
|
||||
luckyCardList.value.push(cardIndex)
|
||||
|
||||
console.log('中奖卡片索引:', cardIndex)
|
||||
|
||||
const item = objects.value[cardIndex]
|
||||
|
||||
// 使用更大的 scale 来显示中奖卡片
|
||||
const luckyScale = 2.5
|
||||
const { xTable, yTable } = useElementPosition(
|
||||
item,
|
||||
rowCount.value,
|
||||
luckyTargets.value.length,
|
||||
{ width: cardSize.value.width, height: cardSize.value.height },
|
||||
windowSize,
|
||||
index,
|
||||
)
|
||||
|
||||
console.log('中奖卡片位置:', { xTable, yTable, scale: luckyScale })
|
||||
|
||||
new TWEEN.Tween(item.position)
|
||||
.to({ x: xTable, y: yTable, z: 1000 }, 1200)
|
||||
.easing(TWEEN.Easing.Exponential.InOut)
|
||||
.onStart(() => {
|
||||
console.log('开始移动中奖卡片,更新为奖品信息')
|
||||
|
||||
// CSS3DObject 的 element 属性
|
||||
const element = (item as any).element
|
||||
|
||||
if (!element) {
|
||||
console.error('element 不存在!')
|
||||
return
|
||||
}
|
||||
|
||||
// 更新卡片内容为奖品信息
|
||||
const prizeResult = currentDrawResult.value!
|
||||
|
||||
console.log('更新卡片内容:', prizeResult)
|
||||
console.log('element 子元素数量:', element.children.length)
|
||||
|
||||
// 更新卡片文本
|
||||
element.children[0].textContent = '' // 不显示序号
|
||||
element.children[1].textContent = prizeResult.prizeName // 奖品名称
|
||||
element.children[2].innerHTML = prizeResult.prizeDescription // 奖品描述
|
||||
|
||||
// 更新样式 - 使用更大的尺寸
|
||||
element.style.backgroundColor = rgba(luckyColor.value, 0.9)
|
||||
element.style.border = `2px solid ${rgba(luckyColor.value, 0.85)}`
|
||||
element.style.boxShadow = `0 0 20px ${rgba(luckyColor.value, 0.8)}`
|
||||
element.style.width = `${cardSize.value.width * luckyScale}px`
|
||||
element.style.height = `${cardSize.value.height * luckyScale}px`
|
||||
element.style.padding = '20px'
|
||||
element.style.display = 'flex'
|
||||
element.style.flexDirection = 'column'
|
||||
element.style.justifyContent = 'center'
|
||||
element.style.alignItems = 'center'
|
||||
element.style.overflow = 'hidden'
|
||||
element.className = 'lucky-element-card'
|
||||
|
||||
// 隐藏序号
|
||||
element.children[0].style.display = 'none'
|
||||
|
||||
// 判断是否是红包奖项,调整描述文字大小
|
||||
const isRedPacket = prizeResult.prizeName.includes('红包')
|
||||
const descFontScale = isRedPacket ? 1.0 : 0.5
|
||||
|
||||
element.children[1].style.fontSize = `${textSize.value * luckyScale * 1.0}px`
|
||||
element.children[1].style.lineHeight = '1.4'
|
||||
element.children[1].style.fontWeight = 'bold'
|
||||
element.children[1].style.color = '#fff'
|
||||
element.children[1].style.textShadow = `0 0 15px ${rgba(luckyColor.value, 0.95)}`
|
||||
element.children[1].style.marginBottom = '15px'
|
||||
element.children[1].style.textAlign = 'center'
|
||||
element.children[1].style.wordWrap = 'break-word'
|
||||
element.children[1].style.maxWidth = '100%'
|
||||
element.children[1].style.whiteSpace = 'normal'
|
||||
element.children[1].style.overflow = 'visible'
|
||||
element.children[1].style.opacity = '1' // 显示文字
|
||||
|
||||
element.children[2].style.fontSize = `${textSize.value * luckyScale * descFontScale}px`
|
||||
element.children[2].style.color = '#fff'
|
||||
element.children[2].style.lineHeight = '1.5'
|
||||
element.children[2].style.textAlign = 'center'
|
||||
element.children[2].style.wordWrap = 'break-word'
|
||||
element.children[2].style.maxWidth = '100%'
|
||||
element.children[2].style.overflow = 'hidden'
|
||||
element.children[2].style.display = '-webkit-box'
|
||||
element.children[2].style.webkitLineClamp = '3'
|
||||
element.children[2].style.webkitBoxOrient = 'vertical'
|
||||
element.children[2].style.opacity = '1' // 显示文字
|
||||
|
||||
console.log('卡片内容已更新:', prizeResult.prizeName)
|
||||
})
|
||||
.onUpdate(render)
|
||||
.start()
|
||||
.onComplete(() => {
|
||||
console.log('中奖卡片移动完成')
|
||||
canOperate.value = true
|
||||
currentStatus.value = LotteryStatus.end
|
||||
})
|
||||
|
||||
new TWEEN.Tween(item.rotation)
|
||||
.to({ x: 0, y: 0, z: 0 }, 900)
|
||||
.easing(TWEEN.Easing.Exponential.InOut)
|
||||
.onUpdate(render)
|
||||
.start()
|
||||
.onComplete(() => {
|
||||
console.log('开始礼花')
|
||||
confettiFire(0, 5)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function continueLottery() {
|
||||
// 检查是否还有奖品可抽
|
||||
if (prizeDrawStore.prizePool.length === 0) {
|
||||
console.log('所有奖品已抽完')
|
||||
currentStatus.value = LotteryStatus.init
|
||||
await transform(targets.table, 1000)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('继续抽奖,重置卡片')
|
||||
|
||||
// 恢复中奖卡片的原始样式
|
||||
luckyCardList.value.forEach((cardIndex) => {
|
||||
const item = objects.value[cardIndex]
|
||||
const element = (item as any).element
|
||||
|
||||
if (element) {
|
||||
const person = tableData.value[cardIndex]
|
||||
|
||||
// 恢复原始内容
|
||||
element.children[0].textContent = person.uid
|
||||
element.children[1].textContent = person.name
|
||||
element.children[2].innerHTML = `${person.department}<br/>${person.identity}`
|
||||
|
||||
// 恢复原始样式
|
||||
element.style.backgroundColor = rgba(cardColor.value, 0.8)
|
||||
element.style.border = `1px solid ${rgba(cardColor.value, 0.75)}`
|
||||
element.style.boxShadow = ''
|
||||
element.style.width = `${cardSize.value.width}px`
|
||||
element.style.height = `${cardSize.value.height}px`
|
||||
element.style.padding = ''
|
||||
element.style.display = ''
|
||||
element.style.flexDirection = ''
|
||||
element.style.justifyContent = ''
|
||||
element.style.alignItems = ''
|
||||
element.style.overflow = ''
|
||||
element.className = 'element-card'
|
||||
|
||||
// 恢复序号显示
|
||||
element.children[0].style.display = ''
|
||||
|
||||
// 恢复文字样式
|
||||
element.children[0].style.fontSize = `${textSize.value * 0.5}px`
|
||||
element.children[0].style.fontWeight = ''
|
||||
element.children[0].style.color = textColor.value
|
||||
element.children[0].style.marginBottom = ''
|
||||
element.children[0].style.whiteSpace = ''
|
||||
element.children[0].style.overflow = ''
|
||||
element.children[0].style.textOverflow = ''
|
||||
|
||||
element.children[1].style.fontSize = `${textSize.value}px`
|
||||
element.children[1].style.lineHeight = `${textSize.value * 3}px`
|
||||
element.children[1].style.fontWeight = ''
|
||||
element.children[1].style.color = textColor.value
|
||||
element.children[1].style.textShadow = ''
|
||||
element.children[1].style.marginBottom = ''
|
||||
element.children[1].style.textAlign = ''
|
||||
element.children[1].style.wordWrap = ''
|
||||
element.children[1].style.maxWidth = ''
|
||||
element.children[1].style.whiteSpace = ''
|
||||
element.children[1].style.overflow = ''
|
||||
|
||||
element.children[2].style.fontSize = `${textSize.value * 0.5}px`
|
||||
element.children[2].style.color = textColor.value
|
||||
element.children[2].style.lineHeight = ''
|
||||
element.children[2].style.textAlign = ''
|
||||
element.children[2].style.wordWrap = ''
|
||||
element.children[2].style.maxWidth = ''
|
||||
element.children[2].style.overflow = ''
|
||||
element.children[2].style.display = ''
|
||||
element.children[2].style.webkitLineClamp = ''
|
||||
element.children[2].style.webkitBoxOrient = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 重置状态
|
||||
luckyTargets.value = []
|
||||
luckyCardList.value = []
|
||||
currentDrawResult.value = null
|
||||
|
||||
// 先将所有卡片移回表格位置,确保整齐
|
||||
await transform(targets.table, 800)
|
||||
|
||||
// 等待一小段时间
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
// 再将所有卡片移到球体
|
||||
await transform(targets.sphere, 1000)
|
||||
|
||||
// 等待球体动画完全完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1200))
|
||||
|
||||
currentStatus.value = LotteryStatus.ready
|
||||
|
||||
// 自动开始下一轮抽奖
|
||||
setTimeout(() => {
|
||||
startLottery()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function quitLottery() {
|
||||
console.log('取消抽奖,恢复卡片')
|
||||
|
||||
// 恢复中奖卡片的原始样式
|
||||
luckyCardList.value.forEach((cardIndex) => {
|
||||
const item = objects.value[cardIndex]
|
||||
const element = (item as any).element
|
||||
|
||||
if (element) {
|
||||
const person = tableData.value[cardIndex]
|
||||
|
||||
// 恢复原始内容
|
||||
element.children[0].textContent = person.uid
|
||||
element.children[1].textContent = person.name
|
||||
element.children[2].innerHTML = `${person.department}<br/>${person.identity}`
|
||||
|
||||
// 恢复原始样式
|
||||
element.style.backgroundColor = rgba(cardColor.value, 0.8)
|
||||
element.style.border = `1px solid ${rgba(cardColor.value, 0.75)}`
|
||||
element.style.boxShadow = ''
|
||||
element.style.width = `${cardSize.value.width}px`
|
||||
element.style.height = `${cardSize.value.height}px`
|
||||
element.style.padding = ''
|
||||
element.style.display = ''
|
||||
element.style.flexDirection = ''
|
||||
element.style.justifyContent = ''
|
||||
element.style.alignItems = ''
|
||||
element.style.overflow = ''
|
||||
element.className = 'element-card'
|
||||
|
||||
// 恢复序号显示
|
||||
element.children[0].style.display = ''
|
||||
|
||||
// 恢复文字样式并隐藏文字
|
||||
element.children[0].style.fontSize = `${textSize.value * 0.5}px`
|
||||
element.children[0].style.fontWeight = ''
|
||||
element.children[0].style.color = textColor.value
|
||||
element.children[0].style.marginBottom = ''
|
||||
element.children[0].style.whiteSpace = ''
|
||||
element.children[0].style.overflow = ''
|
||||
element.children[0].style.textOverflow = ''
|
||||
element.children[0].style.opacity = '0' // 隐藏文字
|
||||
|
||||
element.children[1].style.fontSize = `${textSize.value}px`
|
||||
element.children[1].style.lineHeight = `${textSize.value * 3}px`
|
||||
element.children[1].style.fontWeight = ''
|
||||
element.children[1].style.color = textColor.value
|
||||
element.children[1].style.textShadow = ''
|
||||
element.children[1].style.marginBottom = ''
|
||||
element.children[1].style.textAlign = ''
|
||||
element.children[1].style.wordWrap = ''
|
||||
element.children[1].style.maxWidth = ''
|
||||
element.children[1].style.whiteSpace = ''
|
||||
element.children[1].style.overflow = ''
|
||||
element.children[1].style.opacity = '0' // 隐藏文字
|
||||
|
||||
element.children[2].style.fontSize = `${textSize.value * 0.5}px`
|
||||
element.children[2].style.color = textColor.value
|
||||
element.children[2].style.lineHeight = ''
|
||||
element.children[2].style.textAlign = ''
|
||||
element.children[2].style.wordWrap = ''
|
||||
element.children[2].style.maxWidth = ''
|
||||
element.children[2].style.overflow = ''
|
||||
element.children[2].style.display = ''
|
||||
element.children[2].style.webkitLineClamp = ''
|
||||
element.children[2].style.webkitBoxOrient = ''
|
||||
element.children[2].style.opacity = '0' // 隐藏文字
|
||||
}
|
||||
})
|
||||
|
||||
// 重置状态
|
||||
luckyTargets.value = []
|
||||
luckyCardList.value = []
|
||||
currentDrawResult.value = null
|
||||
|
||||
await transform(targets.table, 1000)
|
||||
currentStatus.value = LotteryStatus.init
|
||||
}
|
||||
|
||||
function setDefaultPersonList() {
|
||||
personConfig.setDefaultPersonList()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
// 弹出确认对话框
|
||||
const confirmed = window.confirm('确定要重置抽奖吗?这将清除所有抽奖记录并重新开始。')
|
||||
|
||||
if (confirmed) {
|
||||
console.log('重置抽奖系统')
|
||||
|
||||
// 重置 prizeDrawStore
|
||||
prizeDrawStore.reset()
|
||||
|
||||
// 重新加载页面
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
TWEEN.removeAll()
|
||||
|
||||
if (animationFrameId.value) {
|
||||
cancelAnimationFrame(animationFrameId.value)
|
||||
}
|
||||
|
||||
if (intervalTimer.value) {
|
||||
clearInterval(intervalTimer.value)
|
||||
}
|
||||
|
||||
if (scene.value) {
|
||||
scene.value.clear()
|
||||
}
|
||||
|
||||
if (controls.value) {
|
||||
controls.value.removeEventListener('change', render)
|
||||
controls.value.dispose()
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
console.log('初始化页面')
|
||||
|
||||
// 初始化 prizeDrawStore
|
||||
if (!prizeDrawStore.isInitialized) {
|
||||
prizeDrawStore.init()
|
||||
}
|
||||
|
||||
// 使用 initTableData 来正确设置 x, y 坐标
|
||||
tableData.value = initTableData({
|
||||
allPersonList: prizeDrawStore.personPool,
|
||||
rowCount: rowCount.value,
|
||||
})
|
||||
|
||||
console.log('卡片数据:', tableData.value.length)
|
||||
console.log('第一张卡片坐标:', tableData.value[0]?.x, tableData.value[0]?.y)
|
||||
|
||||
initThreeJs()
|
||||
containerRef.value!.style.color = textColor.value
|
||||
isInitialDone.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
nextTick(() => {
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prize-draw-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container-3d {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
padding-top: 48px;
|
||||
margin: 0;
|
||||
margin-bottom: 48px;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
line-height: 3rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.prize-info {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
right: 40px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.6);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.reset-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user