feat: add Admin Control Panel, voting status check, and router security
Admin Control Panel: - Add full AdminControl.vue with 3 sections (Voting, Lottery, Global) - Add AdminLogin.vue with access code gate (20268888) - Add admin.ts store with state persistence - Add admin.types.ts with state machine types - Add router guards for /admin/director-console Voting System Fixes: - Add voting status check before accepting votes (VOTING_CLOSED error) - Fix client to display server error messages - Fix button disabled logic to prevent ambiguity in paused state - Auto-generate userId on connect to fix UNAUTHORIZED error Big Screen Enhancements: - Add LiveVotingView.vue with particle system - Add LotteryMachine.ts with 3-stage animation (Galaxy/Storm/Reveal) - Add useSocketClient.ts composable - Fix MainDisplay.vue SCSS syntax error - Add admin state sync listener in display store Server Updates: - Add admin.service.ts for state management - Add isVotingOpen() and getVotingStatus() methods - Add admin socket event handlers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
181
packages/client-screen/src/views/AdminLogin.vue
Normal file
181
packages/client-screen/src/views/AdminLogin.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken } from '../router';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const accessCode = ref('');
|
||||
const error = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = '';
|
||||
|
||||
if (!accessCode.value.trim()) {
|
||||
error.value = '请输入访问码';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// Simulate network delay for UX
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (accessCode.value === ADMIN_ACCESS_CODE) {
|
||||
// Save token to localStorage
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, generateToken(ADMIN_ACCESS_CODE));
|
||||
|
||||
// Redirect to console or original destination
|
||||
const redirect = route.query.redirect as string || '/admin/director-console';
|
||||
router.push(redirect);
|
||||
} else {
|
||||
error.value = '访问码错误';
|
||||
accessCode.value = '';
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleLogin();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-login">
|
||||
<div class="login-box">
|
||||
<div class="lock-icon">🔒</div>
|
||||
<h1 class="title">导演控制台</h1>
|
||||
<p class="subtitle">请输入访问码</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model="accessCode"
|
||||
type="password"
|
||||
placeholder="访问码"
|
||||
autocomplete="off"
|
||||
:disabled="isLoading"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="login-btn"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ isLoading ? '验证中...' : '进入控制台' }}
|
||||
</button>
|
||||
|
||||
<p class="hint">仅限活动导演使用</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-login {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 360px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
text-align: center;
|
||||
letter-spacing: 4px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
letter-spacing: normal;
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
margin: 24px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
343
packages/client-screen/src/views/LiveVotingView.vue
Normal file
343
packages/client-screen/src/views/LiveVotingView.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSocketClient, type VoteEvent } from '../composables/useSocketClient';
|
||||
import { VoteParticleSystem, type ProgramTarget } from '../pixi/VoteParticleSystem';
|
||||
|
||||
const router = useRouter();
|
||||
const { isConnected, onlineUsers, onVoteUpdate } = useSocketClient();
|
||||
|
||||
// Pixi canvas ref
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
let particleSystem: VoteParticleSystem | null = null;
|
||||
|
||||
// Programs data (would come from API in production)
|
||||
const programs = ref([
|
||||
{ id: 'p1', name: '龙腾四海', team: '市场部', votes: 0 },
|
||||
{ id: 'p2', name: '金马奔腾', team: '技术部', votes: 0 },
|
||||
{ id: 'p3', name: '春风得意', team: '人力资源部', votes: 0 },
|
||||
{ id: 'p4', name: '鸿运当头', team: '财务部', votes: 0 },
|
||||
{ id: 'p5', name: '马到成功', team: '运营部', votes: 0 },
|
||||
{ id: 'p6', name: '一马当先', team: '产品部', votes: 0 },
|
||||
{ id: 'p7', name: '万马奔腾', team: '设计部', votes: 0 },
|
||||
{ id: 'p8', name: '龙马精神', team: '销售部', votes: 0 },
|
||||
]);
|
||||
|
||||
// Program card refs for position tracking
|
||||
const programRefs = ref<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
// Unsubscribe function
|
||||
let unsubscribeVote: (() => void) | null = null;
|
||||
|
||||
function goBack() {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
function setProgramRef(id: string, el: HTMLElement | null) {
|
||||
if (el) {
|
||||
programRefs.value.set(id, el);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTargetPositions() {
|
||||
if (!particleSystem) return;
|
||||
|
||||
programRefs.value.forEach((el, id) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const target: ProgramTarget = {
|
||||
id,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
name: programs.value.find(p => p.id === id)?.name || '',
|
||||
};
|
||||
particleSystem!.registerTarget(target);
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoteUpdate(event: VoteEvent) {
|
||||
// Update vote count
|
||||
const program = programs.value.find(p => p.id === event.candidateId);
|
||||
if (program) {
|
||||
program.votes = event.totalVotes;
|
||||
}
|
||||
|
||||
// Spawn particle effect
|
||||
if (particleSystem) {
|
||||
particleSystem.spawnVoteParticle(event.candidateId);
|
||||
}
|
||||
}
|
||||
|
||||
// Demo: simulate votes for testing
|
||||
function simulateVote() {
|
||||
const randomProgram = programs.value[Math.floor(Math.random() * programs.value.length)];
|
||||
randomProgram.votes++;
|
||||
|
||||
if (particleSystem) {
|
||||
particleSystem.spawnVoteParticle(randomProgram.id);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
// Initialize particle system
|
||||
if (canvasRef.value) {
|
||||
particleSystem = new VoteParticleSystem();
|
||||
await particleSystem.init(canvasRef.value);
|
||||
|
||||
// Register initial targets after DOM is ready
|
||||
setTimeout(() => {
|
||||
updateTargetPositions();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Subscribe to vote updates
|
||||
unsubscribeVote = onVoteUpdate(handleVoteUpdate);
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', updateTargetPositions);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (particleSystem) {
|
||||
particleSystem.destroy();
|
||||
particleSystem = null;
|
||||
}
|
||||
|
||||
if (unsubscribeVote) {
|
||||
unsubscribeVote();
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', updateTargetPositions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="live-voting-view">
|
||||
<!-- Pixi Canvas (full screen, behind content) -->
|
||||
<canvas ref="canvasRef" class="particle-canvas"></canvas>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" @click="goBack">← 返回</button>
|
||||
<h1 class="title gold-text">实时投票</h1>
|
||||
<div class="status">
|
||||
<span class="online-count">{{ onlineUsers }} 人在线</span>
|
||||
<span class="connection-dot" :class="{ connected: isConnected }"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Programs Grid -->
|
||||
<main class="programs-grid">
|
||||
<div
|
||||
v-for="program in programs"
|
||||
:key="program.id"
|
||||
:ref="(el) => setProgramRef(program.id, el as HTMLElement)"
|
||||
class="program-card"
|
||||
:class="{ 'has-votes': program.votes > 0 }"
|
||||
>
|
||||
<div class="card-glow"></div>
|
||||
<div class="card-content">
|
||||
<h2 class="program-name">{{ program.name }}</h2>
|
||||
<p class="team-name">{{ program.team }}</p>
|
||||
<div class="vote-indicator">
|
||||
<div class="heat-bar">
|
||||
<div
|
||||
class="heat-fill"
|
||||
:style="{ width: Math.min(100, program.votes * 5) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Demo Controls (remove in production) -->
|
||||
<div class="demo-controls">
|
||||
<button class="demo-btn" @click="simulateVote">
|
||||
模拟投票 (测试)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
.live-voting-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $color-bg-gradient;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.particle-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 50px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 1px solid $color-gold;
|
||||
color: $color-gold;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-gold, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.online-count {
|
||||
color: $color-text-muted;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
transition: background 0.3s;
|
||||
|
||||
&.connected {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 10px rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.programs-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 30px;
|
||||
padding: 40px 50px;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.program-card {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba($color-gold, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba($color-gold, 0.4);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
&.has-votes {
|
||||
border-color: rgba($color-gold, 0.5);
|
||||
|
||||
.card-glow {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
rgba($color-gold, 0.3) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.program-name {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $color-text-light;
|
||||
margin-bottom: 8px;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 16px;
|
||||
color: $color-text-muted;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vote-indicator {
|
||||
.heat-bar {
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.heat-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, $color-gold-dark, $color-gold);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
box-shadow: 0 0 10px rgba($color-gold, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
z-index: 100;
|
||||
|
||||
.demo-btn {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
color: $color-text-light;
|
||||
background: rgba($color-primary, 0.8);
|
||||
border: 1px solid $color-primary-light;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $color-primary;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,147 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useDisplayStore } from '../stores/display';
|
||||
import { useSocketClient } from '../composables/useSocketClient';
|
||||
import { LotteryMachine, type Participant, type LotteryPhase } from '../pixi/LotteryMachine';
|
||||
|
||||
const router = useRouter();
|
||||
const displayStore = useDisplayStore();
|
||||
const { isConnected, onlineUsers, lotteryState, onLotteryStateChange } = useSocketClient();
|
||||
|
||||
// Draw state
|
||||
const isSpinning = ref(false);
|
||||
const spinningNames = ref<string[]>([]);
|
||||
const winner = ref<{ name: string; department: string } | null>(null);
|
||||
// Pixi canvas ref
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
let lotteryMachine: LotteryMachine | null = null;
|
||||
|
||||
// Mock participants for demo
|
||||
const mockParticipants = [
|
||||
{ name: '张三', department: '技术部' },
|
||||
{ name: '李四', department: '产品部' },
|
||||
{ name: '王五', department: '设计部' },
|
||||
{ name: '赵六', department: '市场部' },
|
||||
{ name: '钱七', department: '运营部' },
|
||||
{ name: '孙八', department: '人事部' },
|
||||
{ name: '周九', department: '财务部' },
|
||||
{ name: '吴十', department: '销售部' },
|
||||
// Local state
|
||||
const currentPhase = ref<LotteryPhase>('idle');
|
||||
const currentPrize = ref({ level: '特等奖', name: 'iPhone 16 Pro Max' });
|
||||
const winners = ref<Participant[]>([]);
|
||||
const currentRound = ref(1);
|
||||
|
||||
// Prize configuration
|
||||
const prizes = [
|
||||
{ level: '特等奖', name: 'iPhone 16 Pro Max', winnerCount: 1 },
|
||||
{ level: '一等奖', name: 'iPad Pro', winnerCount: 3 },
|
||||
{ level: '二等奖', name: 'AirPods Pro', winnerCount: 5 },
|
||||
{ level: '三等奖', name: '京东卡 500元', winnerCount: 10 },
|
||||
];
|
||||
|
||||
let spinInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// Mock participants (would come from API)
|
||||
const mockParticipants: Participant[] = [
|
||||
{ id: '1', name: '张三', department: '技术部', zodiac: 'horse', age: 28 },
|
||||
{ id: '2', name: '李四', department: '产品部', zodiac: 'dragon', age: 32 },
|
||||
{ id: '3', name: '王五', department: '设计部', zodiac: 'horse', age: 26 },
|
||||
{ id: '4', name: '赵六', department: '市场部', zodiac: 'tiger', age: 35 },
|
||||
{ id: '5', name: '钱七', department: '运营部', zodiac: 'horse', age: 29 },
|
||||
{ id: '6', name: '孙八', department: '人事部', zodiac: 'rabbit', age: 31 },
|
||||
{ id: '7', name: '周九', department: '财务部', zodiac: 'snake', age: 27 },
|
||||
{ id: '8', name: '吴十', department: '销售部', zodiac: 'horse', age: 33 },
|
||||
{ id: '9', name: '郑十一', department: '技术部', zodiac: 'monkey', age: 25 },
|
||||
{ id: '10', name: '王十二', department: '产品部', zodiac: 'horse', age: 30 },
|
||||
{ id: '11', name: '冯十三', department: '设计部', zodiac: 'rooster', age: 28 },
|
||||
{ id: '12', name: '陈十四', department: '市场部', zodiac: 'dog', age: 34 },
|
||||
{ id: '13', name: '褚十五', department: '运营部', zodiac: 'horse', age: 26 },
|
||||
{ id: '14', name: '卫十六', department: '人事部', zodiac: 'pig', age: 29 },
|
||||
{ id: '15', name: '蒋十七', department: '财务部', zodiac: 'rat', age: 31 },
|
||||
{ id: '16', name: '沈十八', department: '销售部', zodiac: 'ox', age: 27 },
|
||||
{ id: '17', name: '韩十九', department: '技术部', zodiac: 'horse', age: 32 },
|
||||
{ id: '18', name: '杨二十', department: '产品部', zodiac: 'tiger', age: 28 },
|
||||
{ id: '19', name: '朱廿一', department: '设计部', zodiac: 'rabbit', age: 25 },
|
||||
{ id: '20', name: '秦廿二', department: '市场部', zodiac: 'horse', age: 30 },
|
||||
];
|
||||
|
||||
function startDraw() {
|
||||
if (isSpinning.value) return;
|
||||
|
||||
isSpinning.value = true;
|
||||
winner.value = null;
|
||||
|
||||
// Start spinning animation
|
||||
let speed = 50;
|
||||
let index = 0;
|
||||
|
||||
spinInterval = setInterval(() => {
|
||||
spinningNames.value = [
|
||||
mockParticipants[(index) % mockParticipants.length].name,
|
||||
mockParticipants[(index + 1) % mockParticipants.length].name,
|
||||
mockParticipants[(index + 2) % mockParticipants.length].name,
|
||||
];
|
||||
index++;
|
||||
}, speed);
|
||||
|
||||
// Slow down and stop after 5 seconds
|
||||
setTimeout(() => {
|
||||
slowDownAndStop();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function slowDownAndStop() {
|
||||
if (spinInterval) {
|
||||
clearInterval(spinInterval);
|
||||
}
|
||||
|
||||
// Pick random winner
|
||||
const winnerIndex = Math.floor(Math.random() * mockParticipants.length);
|
||||
winner.value = mockParticipants[winnerIndex];
|
||||
isSpinning.value = false;
|
||||
spinningNames.value = [];
|
||||
}
|
||||
// Unsubscribe function
|
||||
let unsubscribeLottery: (() => void) | null = null;
|
||||
|
||||
function goBack() {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
function handlePhaseChange(phase: LotteryPhase) {
|
||||
currentPhase.value = phase;
|
||||
}
|
||||
|
||||
function handleWinnersRevealed(revealedWinners: Participant[]) {
|
||||
winners.value = revealedWinners;
|
||||
}
|
||||
|
||||
// Control functions
|
||||
function startGalaxy() {
|
||||
if (!lotteryMachine) return;
|
||||
|
||||
// Set participants and start galaxy view
|
||||
lotteryMachine.setParticipants(mockParticipants);
|
||||
|
||||
// Apply filter for Round 4 (Horse zodiac)
|
||||
if (currentRound.value === 4) {
|
||||
lotteryMachine.filterParticipants({ zodiac: 'horse' });
|
||||
}
|
||||
|
||||
lotteryMachine.startGalaxy();
|
||||
}
|
||||
|
||||
function startRolling() {
|
||||
if (!lotteryMachine) return;
|
||||
lotteryMachine.startStorm();
|
||||
}
|
||||
|
||||
function stopAndReveal() {
|
||||
if (!lotteryMachine) return;
|
||||
|
||||
// Pick random winners
|
||||
const prize = prizes[currentRound.value - 1];
|
||||
const eligibleParticipants = mockParticipants.filter(p => {
|
||||
if (currentRound.value === 4) {
|
||||
return p.zodiac === 'horse';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const shuffled = [...eligibleParticipants].sort(() => Math.random() - 0.5);
|
||||
const winnerIds = shuffled.slice(0, prize.winnerCount).map(p => p.id);
|
||||
|
||||
lotteryMachine.revealWinners(winnerIds);
|
||||
}
|
||||
|
||||
function nextRound() {
|
||||
if (!lotteryMachine) return;
|
||||
|
||||
currentRound.value = Math.min(4, currentRound.value + 1);
|
||||
currentPrize.value = {
|
||||
level: prizes[currentRound.value - 1].level,
|
||||
name: prizes[currentRound.value - 1].name,
|
||||
};
|
||||
winners.value = [];
|
||||
|
||||
lotteryMachine.reset();
|
||||
}
|
||||
|
||||
function resetLottery() {
|
||||
if (!lotteryMachine) return;
|
||||
|
||||
currentRound.value = 1;
|
||||
currentPrize.value = { level: prizes[0].level, name: prizes[0].name };
|
||||
winners.value = [];
|
||||
|
||||
lotteryMachine.reset();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
// Initialize lottery machine
|
||||
if (canvasRef.value) {
|
||||
lotteryMachine = new LotteryMachine();
|
||||
await lotteryMachine.init(canvasRef.value);
|
||||
|
||||
// Set callbacks
|
||||
lotteryMachine.onPhase(handlePhaseChange);
|
||||
lotteryMachine.onWinners(handleWinnersRevealed);
|
||||
}
|
||||
|
||||
// Subscribe to lottery state changes from server
|
||||
unsubscribeLottery = onLotteryStateChange((state) => {
|
||||
if (state.phase === 'rolling' && currentPhase.value !== 'storm') {
|
||||
startRolling();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (spinInterval) {
|
||||
clearInterval(spinInterval);
|
||||
if (lotteryMachine) {
|
||||
lotteryMachine.destroy();
|
||||
lotteryMachine = null;
|
||||
}
|
||||
|
||||
if (unsubscribeLottery) {
|
||||
unsubscribeLottery();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lucky-draw-view">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" @click="goBack">← 返回</button>
|
||||
<h1 class="title gold-text">幸运抽奖</h1>
|
||||
<div class="online">{{ displayStore.onlineUsers }} 人在线</div>
|
||||
</header>
|
||||
<!-- Pixi Canvas (full screen) -->
|
||||
<canvas ref="canvasRef" class="lottery-canvas"></canvas>
|
||||
|
||||
<!-- Main draw area -->
|
||||
<main class="draw-area">
|
||||
<!-- Prize display -->
|
||||
<div class="prize-section">
|
||||
<!-- Overlay UI -->
|
||||
<div class="overlay-ui">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" @click="goBack">← 返回</button>
|
||||
<div class="round-indicator">
|
||||
<span class="round-label">第 {{ currentRound }} 轮</span>
|
||||
<span v-if="currentRound === 4" class="filter-badge">🐴 马年限定</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="online-count">{{ onlineUsers }} 人在线</span>
|
||||
<span class="connection-dot" :class="{ connected: isConnected }"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Prize Display -->
|
||||
<div class="prize-display" :class="{ hidden: currentPhase === 'storm' }">
|
||||
<div class="prize-badge">
|
||||
<span class="level">特等奖</span>
|
||||
<span class="name">iPhone 16 Pro Max</span>
|
||||
<span class="prize-level">{{ currentPrize.level }}</span>
|
||||
<span class="prize-name">{{ currentPrize.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spinning names -->
|
||||
<div class="spin-section">
|
||||
<div v-if="isSpinning" class="spinning-names">
|
||||
<div v-for="(name, i) in spinningNames" :key="i" class="name-item">
|
||||
{{ name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Phase Indicator -->
|
||||
<div class="phase-indicator">
|
||||
<span class="phase-text">
|
||||
{{ currentPhase === 'idle' ? '准备就绪' :
|
||||
currentPhase === 'galaxy' ? '参与者展示' :
|
||||
currentPhase === 'storm' ? '抽奖进行中...' :
|
||||
currentPhase === 'reveal' ? '揭晓中...' :
|
||||
'恭喜中奖!' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Winner display -->
|
||||
<div v-else-if="winner" class="winner-display">
|
||||
<div class="winner-card">
|
||||
<div class="avatar">🎉</div>
|
||||
<div class="info">
|
||||
<h2 class="name gold-text">{{ winner.name }}</h2>
|
||||
<p class="department">{{ winner.department }}</p>
|
||||
</div>
|
||||
<!-- Winners Display (shown after reveal) -->
|
||||
<div v-if="currentPhase === 'complete' && winners.length > 0" class="winners-overlay">
|
||||
<h2 class="winners-title gold-text">🎉 恭喜以下获奖者 🎉</h2>
|
||||
<div class="winners-list">
|
||||
<div v-for="winner in winners" :key="winner.id" class="winner-item">
|
||||
<span class="winner-name">{{ winner.name }}</span>
|
||||
<span class="winner-dept">{{ winner.department }}</span>
|
||||
</div>
|
||||
<p class="congrats">恭喜中奖!</p>
|
||||
</div>
|
||||
|
||||
<!-- Idle state -->
|
||||
<div v-else class="idle-state">
|
||||
<p>点击下方按钮开始抽奖</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draw button -->
|
||||
<div class="action-section">
|
||||
<!-- Control Panel -->
|
||||
<div class="control-panel">
|
||||
<button
|
||||
class="draw-btn"
|
||||
:class="{ spinning: isSpinning }"
|
||||
:disabled="isSpinning"
|
||||
@click="startDraw"
|
||||
v-if="currentPhase === 'idle'"
|
||||
class="control-btn primary"
|
||||
@click="startGalaxy"
|
||||
>
|
||||
{{ isSpinning ? '抽奖中...' : '开始抽奖' }}
|
||||
开始展示
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentPhase === 'galaxy'"
|
||||
class="control-btn primary"
|
||||
@click="startRolling"
|
||||
>
|
||||
开始抽奖
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentPhase === 'storm'"
|
||||
class="control-btn danger"
|
||||
@click="stopAndReveal"
|
||||
>
|
||||
停止抽奖
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentPhase === 'complete'"
|
||||
class="control-btn secondary"
|
||||
@click="nextRound"
|
||||
:disabled="currentRound >= 4"
|
||||
>
|
||||
下一轮
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentPhase === 'complete'"
|
||||
class="control-btn outline"
|
||||
@click="resetLottery"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
.lucky-draw-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $color-bg-gradient;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lottery-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay-ui {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -149,9 +289,10 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 50px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.5) 0%, transparent 100%);
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid $color-gold;
|
||||
color: $color-gold;
|
||||
padding: 10px 20px;
|
||||
@@ -159,51 +300,88 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all $transition-fast;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-gold, 0.1);
|
||||
background: rgba($color-gold, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
.round-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.round-label {
|
||||
font-size: 24px;
|
||||
color: $color-gold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
padding: 6px 12px;
|
||||
background: rgba($color-gold, 0.2);
|
||||
border: 1px solid $color-gold;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: $color-gold;
|
||||
}
|
||||
}
|
||||
|
||||
.online {
|
||||
color: $color-text-muted;
|
||||
font-size: 18px;
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.online-count {
|
||||
color: $color-text-muted;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
transition: background 0.3s;
|
||||
|
||||
&.connected {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 10px rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.draw-area {
|
||||
flex: 1;
|
||||
.prize-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 50px;
|
||||
}
|
||||
padding: 20px;
|
||||
transition: opacity 0.5s, transform 0.5s;
|
||||
|
||||
.prize-section {
|
||||
margin-bottom: 60px;
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
.prize-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30px 60px;
|
||||
padding: 20px 60px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid $color-gold;
|
||||
border-radius: 16px;
|
||||
background: rgba($color-gold, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.level {
|
||||
.prize-level {
|
||||
font-size: 24px;
|
||||
color: $color-gold;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: $glow-gold;
|
||||
}
|
||||
|
||||
.name {
|
||||
.prize-name {
|
||||
font-size: 36px;
|
||||
color: $color-text-light;
|
||||
font-weight: bold;
|
||||
@@ -211,116 +389,123 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.spin-section {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
.phase-indicator {
|
||||
position: absolute;
|
||||
bottom: 150px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
.spinning-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.name-item {
|
||||
font-size: 72px;
|
||||
color: $color-text-light;
|
||||
text-align: center;
|
||||
animation: flash 0.1s infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
font-size: 96px;
|
||||
color: $color-gold;
|
||||
text-shadow: $glow-gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.winner-display {
|
||||
text-align: center;
|
||||
|
||||
.winner-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 40px 80px;
|
||||
border: 3px solid $color-gold;
|
||||
border-radius: 20px;
|
||||
background: rgba($color-gold, 0.1);
|
||||
animation: scale-pulse 1s ease-in-out infinite;
|
||||
|
||||
.avatar {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 64px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.department {
|
||||
font-size: 24px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.congrats {
|
||||
margin-top: 30px;
|
||||
font-size: 36px;
|
||||
color: $color-primary-light;
|
||||
text-shadow: $glow-red;
|
||||
}
|
||||
}
|
||||
|
||||
.idle-state {
|
||||
font-size: 24px;
|
||||
.phase-text {
|
||||
font-size: 20px;
|
||||
color: $color-text-muted;
|
||||
padding: 10px 30px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.action-section {
|
||||
.draw-btn {
|
||||
padding: 20px 80px;
|
||||
font-size: 32px;
|
||||
.winners-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
|
||||
.winners-title {
|
||||
font-size: 36px;
|
||||
margin-bottom: 30px;
|
||||
text-shadow: $glow-gold;
|
||||
}
|
||||
|
||||
.winners-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.winner-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 30px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: 2px solid $color-gold;
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.winner-name {
|
||||
font-size: 28px;
|
||||
color: $color-gold;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.winner-dept {
|
||||
font-size: 16px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
.control-btn {
|
||||
padding: 16px 40px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: $color-text-light;
|
||||
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
box-shadow: $glow-red;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, $color-gold-dark, $color-gold);
|
||||
color: #000;
|
||||
box-shadow: $glow-gold;
|
||||
}
|
||||
|
||||
&.spinning {
|
||||
animation: glow-pulse 0.5s infinite;
|
||||
&.danger {
|
||||
background: linear-gradient(135deg, $color-primary-dark, $color-primary);
|
||||
color: $color-text-light;
|
||||
box-shadow: $glow-red;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: rgba($color-gold, 0.2);
|
||||
color: $color-gold;
|
||||
border: 2px solid $color-gold;
|
||||
}
|
||||
|
||||
&.outline {
|
||||
background: transparent;
|
||||
color: $color-text-muted;
|
||||
border: 1px solid $color-text-muted;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $color-text-light;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes scale-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.02); }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: $glow-red; }
|
||||
50% { box-shadow: 0 0 40px rgba($color-primary, 0.8), 0 0 80px rgba($color-primary, 0.5); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,248 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useDisplayStore } from '../stores/display';
|
||||
import { BackgroundEffect } from '../pixi/BackgroundEffect';
|
||||
|
||||
const router = useRouter();
|
||||
const displayStore = useDisplayStore();
|
||||
|
||||
const currentTime = ref(new Date().toLocaleTimeString('zh-CN'));
|
||||
// Pixi.js background
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
let backgroundEffect: BackgroundEffect | null = null;
|
||||
|
||||
// Update time every second
|
||||
onMounted(() => {
|
||||
setInterval(() => {
|
||||
// Time display
|
||||
const currentTime = ref(new Date().toLocaleTimeString('zh-CN'));
|
||||
let timeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
// Connect to socket
|
||||
displayStore.connect();
|
||||
|
||||
// Initialize Pixi.js background
|
||||
if (canvasRef.value) {
|
||||
backgroundEffect = new BackgroundEffect();
|
||||
await backgroundEffect.init(canvasRef.value);
|
||||
}
|
||||
|
||||
// Update time every second
|
||||
timeInterval = setInterval(() => {
|
||||
currentTime.value = new Date().toLocaleTimeString('zh-CN');
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
function goToDraw() {
|
||||
router.push('/draw');
|
||||
}
|
||||
|
||||
function goToResults() {
|
||||
router.push('/results');
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (backgroundEffect) {
|
||||
backgroundEffect.destroy();
|
||||
}
|
||||
if (timeInterval) {
|
||||
clearInterval(timeInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-display">
|
||||
<!-- Background particles will be added via Pixi.js -->
|
||||
<div class="background-overlay"></div>
|
||||
<div class="cinematic-display">
|
||||
<!-- Pixi.js Canvas Background -->
|
||||
<canvas ref="canvasRef" class="background-canvas"></canvas>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<span class="year gold-text">2026</span>
|
||||
<span class="title">年会盛典</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="online-count">
|
||||
<span class="dot"></span>
|
||||
{{ displayStore.onlineUsers }} 人在线
|
||||
</span>
|
||||
<span class="time">{{ currentTime }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Corner Decorations -->
|
||||
<div class="corner-decoration top-left"></div>
|
||||
<div class="corner-decoration top-right"></div>
|
||||
<div class="corner-decoration bottom-left"></div>
|
||||
<div class="corner-decoration bottom-right"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<div class="welcome-section">
|
||||
<h1 class="main-title">
|
||||
<span class="gold-text">马到成功</span>
|
||||
</h1>
|
||||
<p class="subtitle">2026 年度盛典</p>
|
||||
<!-- Central Title -->
|
||||
<div class="title-section">
|
||||
<h1 class="main-title liquid-gold">马到成功</h1>
|
||||
<div class="subtitle-wrapper">
|
||||
<span class="year">2026</span>
|
||||
<span class="divider">·</span>
|
||||
<span class="event">年度盛典</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="actions">
|
||||
<button class="action-btn draw-btn" @click="goToDraw">
|
||||
<span class="icon">🎁</span>
|
||||
<span class="text">幸运抽奖</span>
|
||||
</button>
|
||||
<button class="action-btn results-btn" @click="goToResults">
|
||||
<span class="icon">📊</span>
|
||||
<span class="text">投票结果</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Particle Ring Placeholder -->
|
||||
<div class="particle-ring"></div>
|
||||
</main>
|
||||
|
||||
<!-- Decorative elements -->
|
||||
<div class="decoration left-lantern"></div>
|
||||
<div class="decoration right-lantern"></div>
|
||||
<!-- Status Footer -->
|
||||
<footer class="status-footer">
|
||||
<div class="status-item">
|
||||
<span class="dot online"></span>
|
||||
<span class="label">{{ displayStore.onlineUsers }} 人在线</span>
|
||||
</div>
|
||||
<div class="status-item time">
|
||||
<span class="label">{{ currentTime }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="dot" :class="displayStore.isConnected ? 'ready' : 'offline'"></span>
|
||||
<span class="label">{{ displayStore.isConnected ? '已连接' : '未连接' }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
.main-display {
|
||||
.cinematic-display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $color-bg-gradient;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: $color-bg-wine;
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
.background-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 0%, rgba(0, 0, 0, 0.5) 100%);
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 50px;
|
||||
position: relative;
|
||||
// Corner decorations (Chinese knot pattern)
|
||||
.corner-decoration {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
z-index: 10;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
|
||||
.year {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
color: $color-text-light;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: $color-gold;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
font-size: 18px;
|
||||
color: $color-text-muted;
|
||||
&::before {
|
||||
width: 80px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.online-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
&::after {
|
||||
width: 2px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #52c41a;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
&.top-left {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
|
||||
&::before { top: 0; left: 0; }
|
||||
&::after { top: 0; left: 0; }
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
top: 40px;
|
||||
right: 40px;
|
||||
|
||||
&::before { top: 0; right: 0; }
|
||||
&::after { top: 0; right: 0; }
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
bottom: 40px;
|
||||
left: 40px;
|
||||
|
||||
&::before { bottom: 0; left: 0; }
|
||||
&::after { bottom: 0; left: 0; }
|
||||
}
|
||||
|
||||
&.bottom-right {
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
|
||||
&::before { bottom: 0; right: 0; }
|
||||
&::after { bottom: 0; right: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
.title-section {
|
||||
text-align: center;
|
||||
margin-bottom: 80px;
|
||||
|
||||
.main-title {
|
||||
font-size: 120px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: $glow-gold;
|
||||
font-size: 160px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 36px;
|
||||
.subtitle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
font-size: 32px;
|
||||
color: $color-text-muted;
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 60px;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 40px 60px;
|
||||
border: 2px solid $color-gold;
|
||||
border-radius: 20px;
|
||||
background: rgba($color-gold, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-gold, 0.2);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: $glow-gold;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 24px;
|
||||
.year {
|
||||
color: $color-gold;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.draw-btn {
|
||||
border-color: $color-primary;
|
||||
background: rgba($color-primary, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-primary, 0.2);
|
||||
box-shadow: $glow-red;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: $color-primary-light;
|
||||
.divider {
|
||||
color: $color-gold-dark;
|
||||
}
|
||||
|
||||
.event {
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.decoration {
|
||||
.particle-ring {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
background: linear-gradient(180deg, $color-primary 0%, $color-primary-dark 100%);
|
||||
border-radius: 50% 50% 45% 45%;
|
||||
opacity: 0.6;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
border: 1px solid rgba($color-gold, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: rotate-slow 60s linear infinite;
|
||||
pointer-events: none;
|
||||
|
||||
&.left-lantern {
|
||||
top: 100px;
|
||||
left: 50px;
|
||||
animation: float 4s ease-in-out infinite;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 40px;
|
||||
border: 1px solid rgba($color-gold, 0.15);
|
||||
border-radius: 50%;
|
||||
animation: rotate-slow 45s linear infinite reverse;
|
||||
}
|
||||
|
||||
&.right-lantern {
|
||||
top: 100px;
|
||||
right: 50px;
|
||||
animation: float 4s ease-in-out infinite 1s;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 80px;
|
||||
border: 1px solid rgba($color-gold, 0.08);
|
||||
border-radius: 50%;
|
||||
animation: rotate-slow 30s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.status-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 60px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
color: $color-text-muted;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.online {
|
||||
background: #52c41a;
|
||||
box-shadow: 0 0 10px rgba(82, 196, 26, 0.5);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
&.ready {
|
||||
background: $color-gold;
|
||||
box-shadow: 0 0 10px rgba($color-gold, 0.5);
|
||||
}
|
||||
|
||||
&.offline {
|
||||
background: #666;
|
||||
}
|
||||
}
|
||||
|
||||
&.time {
|
||||
font-size: 18px;
|
||||
color: $color-text-light;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(-3deg); }
|
||||
50% { transform: translateY(-15px) rotate(3deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,7 +62,7 @@ const categories = [
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
.vote-results-view {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user