Files
log-lottery/src/views/PrizeDraw/index.vue
kkfluous 25d0c95dc3 feat: 🎁 新增破冰抽奖功能及 82 人名单
- 新增 src/views/PrizeDraw 抽奖视图及抽奖配置 store
- 更新 defaultPersonList 为 82 位真实参与者名单
- 调整主页、路由、i18n 及音乐播放以支持抽奖入口
- 附抽奖需求及实现报告文档

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:29:52 +08:00

833 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>