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

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