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:
kkfluous
2026-04-24 16:29:52 +08:00
parent d26c364999
commit 25d0c95dc3
26 changed files with 21649 additions and 281 deletions

View File

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

View File

@@ -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: 继续,意味着这抽奖作数,计入数据库

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

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

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

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