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:
empty
2026-01-15 15:34:37 +08:00
parent e7397d22a9
commit 30cd29d45d
45 changed files with 7791 additions and 715 deletions

View File

@@ -1,10 +1,21 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useDisplayStore } from './stores/display';
const router = useRouter();
const route = useRoute();
const displayStore = useDisplayStore();
const isFullscreen = ref(false);
// Mode to route mapping
const modeRoutes: Record<string, string> = {
'idle': '/',
'voting': '/screen/voting',
'draw': '/screen/draw',
'results': '/screen/results',
};
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
@@ -15,6 +26,18 @@ function toggleFullscreen() {
}
}
// Handle mode change from director console
function handleModeChange(event: Event) {
const { mode } = (event as CustomEvent).detail;
const targetRoute = modeRoutes[mode];
// Only navigate if we're on a screen route (not admin routes)
if (targetRoute && !route.path.startsWith('/admin')) {
console.log('[App] Navigating to:', targetRoute);
router.push(targetRoute);
}
}
onMounted(() => {
displayStore.connect();
@@ -22,10 +45,14 @@ onMounted(() => {
document.addEventListener('fullscreenchange', () => {
isFullscreen.value = !!document.fullscreenElement;
});
// Listen for mode changes from director console
window.addEventListener('screen:mode_change', handleModeChange);
});
onUnmounted(() => {
displayStore.disconnect();
window.removeEventListener('screen:mode_change', handleModeChange);
});
</script>

View File

@@ -1,4 +1,4 @@
@import './variables.scss';
@use './variables.scss' as *;
* {
margin: 0;
@@ -25,6 +25,35 @@ html, body {
background-clip: text;
}
// Liquid Gold shimmer effect
.liquid-gold {
font-family: 'Noto Serif SC', 'Ma Shan Zheng', serif;
background: linear-gradient(
90deg,
$color-gold-dark 0%,
$color-gold 25%,
$color-gold-light 50%,
$color-gold 75%,
$color-gold-dark 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: shimmer 3s ease-in-out infinite;
text-shadow: none;
filter: drop-shadow(0 0 20px rgba($color-gold, 0.4));
}
@keyframes shimmer {
0%, 100% {
background-position: 200% center;
}
50% {
background-position: 0% center;
}
}
// Red glow text
.glow-text {
text-shadow: $glow-red;

View File

@@ -1,17 +1,20 @@
// Guochao Red & Gold Theme for Big Screen
// Guochao Red & Gold Theme for Big Screen - Cinematic Edition
// Primary colors
$color-primary: #c41230;
// Primary colors (Dan-sha Red)
$color-primary: #c21f30;
$color-primary-light: #e8384f;
$color-primary-dark: #9a0e26;
$color-gold: #d4a84b;
$color-gold-light: #f0c96a;
$color-gold-dark: #b8923f;
// Gold palette (Champagne Gold)
$color-gold: #f0c239;
$color-gold-light: #ffd966;
$color-gold-dark: #d4a84b;
$color-gold-deep: #b8923f;
// Background
// Cinematic background
$color-bg-dark: #0a0a0a;
$color-bg-gradient: linear-gradient(180deg, #1a0a0a 0%, #0a0a0a 50%, #0a0510 100%);
$color-bg-wine: #2a0a0e;
$color-bg-gradient: linear-gradient(180deg, #2a0a0e 0%, #1a0808 50%, #0a0505 100%);
// Text
$color-text-light: #ffffff;

View File

@@ -0,0 +1,283 @@
import { ref, shallowRef, onMounted, onUnmounted, readonly } from 'vue';
import { io, Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
DrawStartPayload,
DrawSpinPayload,
DrawWinnerPayload,
VoteUpdatePayload,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
// ============================================================================
// Types
// ============================================================================
export interface LotteryState {
phase: 'idle' | 'rolling' | 'revealing' | 'complete';
prizeLevel: string | null;
prizeName: string | null;
participantCount: number;
winners: Array<{
odrawId: string;
visitorId: string;
userId: string;
userName: string;
department: string;
avatar?: string;
}>;
spinningNames: string[];
}
export interface VoteEvent {
candidateId: string;
category: string;
totalVotes: number;
delta: number;
timestamp: number;
}
export type VoteUpdateCallback = (event: VoteEvent) => void;
export type LotteryStateCallback = (state: LotteryState) => void;
// ============================================================================
// Composable
// ============================================================================
export function useSocketClient() {
// Connection state
const socket = shallowRef<GalaSocket | null>(null);
const isConnected = ref(false);
const isConnecting = ref(false);
const onlineUsers = ref(0);
const latency = ref(0);
// Lottery state
const lotteryState = ref<LotteryState>({
phase: 'idle',
prizeLevel: null,
prizeName: null,
participantCount: 0,
winners: [],
spinningNames: [],
});
// Event callbacks
const voteCallbacks = new Set<VoteUpdateCallback>();
const lotteryCallbacks = new Set<LotteryStateCallback>();
// Heartbeat
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let lastPingTime = 0;
// ============================================================================
// Connection Management
// ============================================================================
function connect(): void {
if (socket.value?.connected || isConnecting.value) {
return;
}
isConnecting.value = true;
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 10000,
transports: ['websocket', 'polling'],
});
// Connection events
socketInstance.on('connect', () => {
console.log('[BigScreen] Connected to server');
isConnected.value = true;
isConnecting.value = false;
// Join as screen display
socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, {
userId: `screen_${Date.now()}`,
userName: 'Big Screen Display',
role: 'screen',
}, () => {});
// Start heartbeat
startHeartbeat();
});
socketInstance.on('disconnect', (reason) => {
console.log('[BigScreen] Disconnected:', reason);
isConnected.value = false;
stopHeartbeat();
});
socketInstance.on('connect_error', (error) => {
console.error('[BigScreen] Connection error:', error);
isConnecting.value = false;
});
// User count updates
socketInstance.on('connection:users_count' as any, (count: number) => {
onlineUsers.value = count;
});
// Pong response
socketInstance.on('connection:pong' as any, () => {
latency.value = Date.now() - lastPingTime;
});
// Vote updates
socketInstance.on('vote:updated' as any, (data: VoteUpdatePayload) => {
const event: VoteEvent = {
...data,
timestamp: Date.now(),
};
// Notify all registered callbacks
voteCallbacks.forEach(cb => cb(event));
});
// Lottery events
socketInstance.on('draw:started' as any, (data: DrawStartPayload) => {
lotteryState.value = {
phase: 'rolling',
prizeLevel: data.prizeLevel,
prizeName: data.prizeName,
participantCount: data.participantCount,
winners: [],
spinningNames: [],
};
notifyLotteryCallbacks();
});
socketInstance.on('draw:spinning' as any, (data: DrawSpinPayload) => {
lotteryState.value.spinningNames = data.currentNames;
lotteryState.value.phase = 'rolling';
notifyLotteryCallbacks();
});
socketInstance.on('draw:winner' as any, (data: DrawWinnerPayload) => {
lotteryState.value.phase = 'revealing';
lotteryState.value.winners.push(data.winner);
notifyLotteryCallbacks();
});
socketInstance.on('draw:ended' as any, () => {
lotteryState.value.phase = 'complete';
notifyLotteryCallbacks();
// Reset to idle after delay
setTimeout(() => {
lotteryState.value.phase = 'idle';
notifyLotteryCallbacks();
}, 10000);
});
socket.value = socketInstance as GalaSocket;
}
function disconnect(): void {
stopHeartbeat();
if (socket.value) {
socket.value.disconnect();
socket.value = null;
}
isConnected.value = false;
isConnecting.value = false;
}
// ============================================================================
// Heartbeat
// ============================================================================
function startHeartbeat(): void {
stopHeartbeat();
heartbeatInterval = setInterval(() => {
if (socket.value?.connected) {
lastPingTime = Date.now();
socket.value.emit(SOCKET_EVENTS.CONNECTION_PING as any);
}
}, 5000);
}
function stopHeartbeat(): void {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
}
// ============================================================================
// Event Subscription
// ============================================================================
function onVoteUpdate(callback: VoteUpdateCallback): () => void {
voteCallbacks.add(callback);
return () => voteCallbacks.delete(callback);
}
function onLotteryStateChange(callback: LotteryStateCallback): () => void {
lotteryCallbacks.add(callback);
return () => lotteryCallbacks.delete(callback);
}
function notifyLotteryCallbacks(): void {
lotteryCallbacks.forEach(cb => cb(lotteryState.value));
}
// ============================================================================
// Admin Controls (for testing)
// ============================================================================
function startLottery(prizeLevel: string): void {
if (!socket.value?.connected) return;
socket.value.emit('draw:start' as any, {
prizeLevel,
filters: {},
}, () => {});
}
function stopLottery(): void {
if (!socket.value?.connected) return;
socket.value.emit('draw:stop' as any, () => {});
}
// ============================================================================
// Lifecycle
// ============================================================================
onMounted(() => {
connect();
});
onUnmounted(() => {
disconnect();
});
return {
// State (readonly)
isConnected: readonly(isConnected),
isConnecting: readonly(isConnecting),
onlineUsers: readonly(onlineUsers),
latency: readonly(latency),
lotteryState: readonly(lotteryState),
// Methods
connect,
disconnect,
onVoteUpdate,
onLotteryStateChange,
startLottery,
stopLottery,
// Socket access (for advanced usage)
getSocket: () => socket.value,
};
}

View File

@@ -0,0 +1,283 @@
import { Application, Container, Graphics, Ticker } from 'pixi.js';
// Cinematic color palette
const COLORS = {
wineRed: 0x2a0a0e,
gold: 0xf0c239,
goldDark: 0xd4a84b,
goldLight: 0xffd700,
};
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
alpha: number;
rotation: number;
rotationSpeed: number;
type: 'dust' | 'symbol' | 'streak';
life: number;
maxLife: number;
}
export class BackgroundEffect {
private app: Application;
private container: Container;
private particles: Particle[] = [];
private dustLayer: Graphics;
private symbolLayer: Graphics;
private streakLayer: Graphics;
private time = 0;
private windOffset = 0;
private isDestroyed = false;
// Configuration
private readonly DUST_COUNT = 120;
private readonly SYMBOL_COUNT = 25;
private readonly STREAK_INTERVAL = 3000; // ms between streaks
constructor() {
this.app = new Application();
this.container = new Container();
this.dustLayer = new Graphics();
this.symbolLayer = new Graphics();
this.streakLayer = new Graphics();
}
async init(canvas: HTMLCanvasElement): Promise<void> {
await this.app.init({
canvas,
width: window.innerWidth,
height: window.innerHeight,
backgroundColor: COLORS.wineRed,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
// Setup layers (back to front)
this.container.addChild(this.dustLayer);
this.container.addChild(this.symbolLayer);
this.container.addChild(this.streakLayer);
this.app.stage.addChild(this.container);
// Draw vignette overlay
this.drawVignette();
// Initialize particles
this.initDustParticles();
this.initSymbolParticles();
// Start animation loop
this.app.ticker.add(this.update.bind(this));
// Spawn streaks periodically
this.spawnStreakLoop();
// Handle resize
window.addEventListener('resize', this.handleResize.bind(this));
}
private drawVignette(): void {
const vignette = new Graphics();
const w = this.app.screen.width;
const h = this.app.screen.height;
const cx = w / 2;
const cy = h / 2;
const maxRadius = Math.sqrt(cx * cx + cy * cy);
// Create radial gradient effect with multiple circles
for (let i = 20; i >= 0; i--) {
const ratio = i / 20;
const radius = maxRadius * (0.3 + ratio * 0.7);
const alpha = ratio * 0.8;
vignette.circle(cx, cy, radius);
vignette.fill({ color: 0x000000, alpha });
}
this.app.stage.addChildAt(vignette, 0);
}
private initDustParticles(): void {
const w = this.app.screen.width;
const h = this.app.screen.height;
for (let i = 0; i < this.DUST_COUNT; i++) {
this.particles.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.2,
size: Math.random() * 2 + 1,
alpha: Math.random() * 0.3 + 0.1,
rotation: 0,
rotationSpeed: 0,
type: 'dust',
life: 0,
maxLife: Infinity,
});
}
}
private initSymbolParticles(): void {
const w = this.app.screen.width;
const h = this.app.screen.height;
for (let i = 0; i < this.SYMBOL_COUNT; i++) {
this.particles.push({
x: Math.random() * w,
y: h + Math.random() * 100,
vx: (Math.random() - 0.5) * 0.5,
vy: -Math.random() * 0.8 - 0.3,
size: Math.random() * 8 + 4,
alpha: Math.random() * 0.4 + 0.2,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.02,
type: 'symbol',
life: 0,
maxLife: Infinity,
});
}
}
private spawnStreak(): void {
if (this.isDestroyed) return;
const w = this.app.screen.width;
const h = this.app.screen.height;
const startX = Math.random() * w * 0.3;
const startY = Math.random() * h * 0.3;
this.particles.push({
x: startX,
y: startY,
vx: Math.random() * 8 + 6,
vy: Math.random() * 4 + 3,
size: Math.random() * 3 + 2,
alpha: 0.9,
rotation: Math.atan2(4, 8),
rotationSpeed: 0,
type: 'streak',
life: 0,
maxLife: 60, // frames
});
}
private spawnStreakLoop(): void {
if (this.isDestroyed) return;
this.spawnStreak();
setTimeout(() => this.spawnStreakLoop(), this.STREAK_INTERVAL + Math.random() * 2000);
}
private update(ticker: Ticker): void {
if (this.isDestroyed) return;
this.time += ticker.deltaTime;
this.windOffset = Math.sin(this.time * 0.01) * 0.5;
// Clear graphics
this.dustLayer.clear();
this.symbolLayer.clear();
this.streakLayer.clear();
const w = this.app.screen.width;
const h = this.app.screen.height;
// Update and draw particles
this.particles = this.particles.filter((p) => {
// Update position with wind
p.x += p.vx + this.windOffset * (p.type === 'dust' ? 0.3 : 0.1);
p.y += p.vy;
p.rotation += p.rotationSpeed;
p.life++;
// Check bounds and lifecycle
if (p.type === 'streak') {
if (p.life >= p.maxLife || p.x > w || p.y > h) {
return false;
}
p.alpha = 0.9 * (1 - p.life / p.maxLife);
} else if (p.type === 'symbol') {
// Reset symbols that go off top
if (p.y < -20) {
p.y = h + 20;
p.x = Math.random() * w;
}
} else {
// Wrap dust particles
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
}
// Draw particle
this.drawParticle(p);
return true;
});
}
private drawParticle(p: Particle): void {
const layer = p.type === 'dust'
? this.dustLayer
: p.type === 'symbol'
? this.symbolLayer
: this.streakLayer;
if (p.type === 'dust') {
// Simple gold circle
layer.circle(p.x, p.y, p.size);
layer.fill({ color: COLORS.gold, alpha: p.alpha });
} else if (p.type === 'symbol') {
// Fortune symbols (circles and squares)
layer.setTransform(p.x, p.y, 1, 1, p.rotation);
if (Math.random() > 0.5) {
// Circle
layer.circle(0, 0, p.size);
} else {
// Square/diamond
layer.rect(-p.size / 2, -p.size / 2, p.size, p.size);
}
layer.fill({ color: COLORS.goldDark, alpha: p.alpha });
layer.setTransform(0, 0, 1, 1, 0);
} else if (p.type === 'streak') {
// Gold streak with trail
const trailLength = 80;
const dx = Math.cos(p.rotation);
const dy = Math.sin(p.rotation);
// Draw gradient trail
for (let i = 0; i < 10; i++) {
const ratio = i / 10;
const tx = p.x - dx * trailLength * ratio;
const ty = p.y - dy * trailLength * ratio;
const ta = p.alpha * (1 - ratio) * 0.5;
const ts = p.size * (1 - ratio * 0.5);
layer.circle(tx, ty, ts);
layer.fill({ color: COLORS.goldLight, alpha: ta });
}
// Draw head
layer.circle(p.x, p.y, p.size);
layer.fill({ color: COLORS.goldLight, alpha: p.alpha });
}
}
private handleResize(): void {
if (this.isDestroyed) return;
this.app.renderer.resize(window.innerWidth, window.innerHeight);
}
destroy(): void {
this.isDestroyed = true;
window.removeEventListener('resize', this.handleResize.bind(this));
this.app.destroy(true, { children: true });
}
}

View File

@@ -0,0 +1,752 @@
import { Application, Container, Graphics, Text, TextStyle, Ticker, BlurFilter } from 'pixi.js';
// ============================================================================
// Constants
// ============================================================================
const COLORS = {
gold: 0xf0c239,
goldLight: 0xffd700,
goldDark: 0xd4a84b,
red: 0xc21f30,
redLight: 0xe63946,
redDark: 0x8b0000,
wineRed: 0x2a0a0e,
black: 0x000000,
white: 0xffffff,
};
// Easing functions
const Easing = {
easeInOutCubic: (t: number): number => {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
},
easeOutQuart: (t: number): number => {
return 1 - Math.pow(1 - t, 4);
},
easeInQuart: (t: number): number => {
return t * t * t * t;
},
easeOutBack: (t: number): number => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
easeOutElastic: (t: number): number => {
const c4 = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},
};
// ============================================================================
// Types
// ============================================================================
export interface Participant {
id: string;
name: string;
department: string;
zodiac?: string;
age?: number;
avatar?: string;
isEligible?: boolean;
}
export type LotteryPhase = 'idle' | 'galaxy' | 'storm' | 'reveal' | 'complete';
interface NameParticle {
id: string;
name: string;
x: number;
y: number;
z: number; // For 3D sphere effect
targetX: number;
targetY: number;
vx: number;
vy: number;
vz: number;
rotation: number;
rotationSpeed: number;
scale: number;
alpha: number;
isEligible: boolean;
isWinner: boolean;
text: Text | null;
}
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
rotation: number;
rotationSpeed: number;
width: number;
height: number;
color: number;
alpha: number;
gravity: number;
}
// ============================================================================
// LotteryMachine Class
// ============================================================================
export class LotteryMachine {
private app: Application;
private mainContainer: Container;
private galaxyContainer: Container;
private stormContainer: Container;
private revealContainer: Container;
private confettiContainer: Container;
private scrollContainer: Container;
private dimOverlay: Graphics;
private nameParticles: NameParticle[] = [];
private confettiParticles: ConfettiParticle[] = [];
private participants: Participant[] = [];
private winners: Participant[] = [];
private phase: LotteryPhase = 'idle';
private phaseTime = 0;
private time = 0;
private isDestroyed = false;
// Galaxy parameters
private sphereRadius = 300;
private sphereRotationX = 0;
private sphereRotationY = 0;
private sphereRotationZ = 0;
// Storm parameters
private stormIntensity = 0;
private stormAngle = 0;
private blurFilter: BlurFilter;
// Reveal parameters
private revealProgress = 0;
private scrollScale = 0;
// Text style
private nameStyle: TextStyle;
private winnerStyle: TextStyle;
// Callbacks
private onPhaseChange?: (phase: LotteryPhase) => void;
private onWinnersRevealed?: (winners: Participant[]) => void;
constructor() {
this.app = new Application();
this.mainContainer = new Container();
this.galaxyContainer = new Container();
this.stormContainer = new Container();
this.revealContainer = new Container();
this.confettiContainer = new Container();
this.scrollContainer = new Container();
this.dimOverlay = new Graphics();
this.blurFilter = new BlurFilter({ strength: 0 });
this.nameStyle = new TextStyle({
fontFamily: '"Noto Serif SC", serif',
fontSize: 18,
fill: COLORS.gold,
fontWeight: 'bold',
});
this.winnerStyle = new TextStyle({
fontFamily: '"Noto Serif SC", serif',
fontSize: 48,
fill: COLORS.goldLight,
fontWeight: 'bold',
dropShadow: {
color: COLORS.gold,
blur: 10,
distance: 0,
},
});
}
async init(canvas: HTMLCanvasElement): Promise<void> {
await this.app.init({
canvas,
width: window.innerWidth,
height: window.innerHeight,
backgroundColor: COLORS.wineRed,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
// Setup layer hierarchy
this.mainContainer.addChild(this.galaxyContainer);
this.mainContainer.addChild(this.stormContainer);
this.mainContainer.addChild(this.dimOverlay);
this.mainContainer.addChild(this.scrollContainer);
this.mainContainer.addChild(this.revealContainer);
this.mainContainer.addChild(this.confettiContainer);
this.app.stage.addChild(this.mainContainer);
// Apply blur filter to storm container
this.stormContainer.filters = [this.blurFilter];
// Start animation loop
this.app.ticker.add(this.update.bind(this));
// Handle resize
window.addEventListener('resize', this.handleResize.bind(this));
}
// ============================================================================
// Public API
// ============================================================================
/**
* Set participants for the lottery
*/
setParticipants(participants: Participant[]): void {
this.participants = participants;
this.createNameParticles();
}
/**
* Filter participants by criteria (e.g., zodiac for Round 4)
*/
filterParticipants(filter: { zodiac?: string; ageRange?: { min: number; max: number } }): void {
this.nameParticles.forEach((p) => {
const participant = this.participants.find((part) => part.id === p.id);
if (!participant) return;
let eligible = true;
if (filter.zodiac && participant.zodiac !== filter.zodiac) {
eligible = false;
}
if (filter.ageRange) {
const age = participant.age || 0;
if (age < filter.ageRange.min || age > filter.ageRange.max) {
eligible = false;
}
}
p.isEligible = eligible;
});
}
/**
* Start the lottery - transition to Galaxy phase
*/
startGalaxy(): void {
this.phase = 'galaxy';
this.phaseTime = 0;
this.onPhaseChange?.('galaxy');
}
/**
* Start the storm - rolling phase
*/
startStorm(): void {
this.phase = 'storm';
this.phaseTime = 0;
this.stormIntensity = 0;
this.onPhaseChange?.('storm');
}
/**
* Stop and reveal winners
*/
revealWinners(winnerIds: string[]): void {
this.winners = this.participants.filter((p) => winnerIds.includes(p.id));
// Mark winner particles
this.nameParticles.forEach((p) => {
p.isWinner = winnerIds.includes(p.id);
});
this.phase = 'reveal';
this.phaseTime = 0;
this.revealProgress = 0;
this.scrollScale = 0;
this.onPhaseChange?.('reveal');
}
/**
* Reset to idle state
*/
reset(): void {
this.phase = 'idle';
this.phaseTime = 0;
this.winners = [];
this.confettiParticles = [];
this.stormIntensity = 0;
this.blurFilter.strength = 0;
// Reset particles
this.nameParticles.forEach((p) => {
p.isWinner = false;
});
// Clear containers
this.revealContainer.removeChildren();
this.scrollContainer.removeChildren();
this.confettiContainer.removeChildren();
this.dimOverlay.clear();
this.onPhaseChange?.('idle');
}
/**
* Set callbacks
*/
onPhase(callback: (phase: LotteryPhase) => void): void {
this.onPhaseChange = callback;
}
onWinners(callback: (winners: Participant[]) => void): void {
this.onWinnersRevealed = callback;
}
// ============================================================================
// Particle Creation
// ============================================================================
private createNameParticles(): void {
// Clear existing
this.galaxyContainer.removeChildren();
this.stormContainer.removeChildren();
this.nameParticles = [];
const centerX = this.app.screen.width / 2;
const centerY = this.app.screen.height / 2;
this.participants.forEach((participant, index) => {
// Distribute on sphere using fibonacci spiral
const phi = Math.acos(1 - 2 * (index + 0.5) / this.participants.length);
const theta = Math.PI * (1 + Math.sqrt(5)) * index;
const x = this.sphereRadius * Math.sin(phi) * Math.cos(theta);
const y = this.sphereRadius * Math.sin(phi) * Math.sin(theta);
const z = this.sphereRadius * Math.cos(phi);
// Create text
const text = new Text({
text: participant.name,
style: this.nameStyle,
});
text.anchor.set(0.5);
this.galaxyContainer.addChild(text);
const particle: NameParticle = {
id: participant.id,
name: participant.name,
x: centerX + x,
y: centerY + y,
z,
targetX: centerX,
targetY: centerY,
vx: 0,
vy: 0,
vz: 0,
rotation: 0,
rotationSpeed: (Math.random() - 0.5) * 0.1,
scale: 1,
alpha: 1,
isEligible: participant.isEligible !== false,
isWinner: false,
text,
};
this.nameParticles.push(particle);
});
}
// ============================================================================
// Animation Loop
// ============================================================================
private update(ticker: Ticker): void {
if (this.isDestroyed) return;
this.time += ticker.deltaMS;
this.phaseTime += ticker.deltaMS;
switch (this.phase) {
case 'galaxy':
this.updateGalaxy(ticker.deltaMS);
break;
case 'storm':
this.updateStorm(ticker.deltaMS);
break;
case 'reveal':
this.updateReveal(ticker.deltaMS);
break;
case 'complete':
this.updateComplete(ticker.deltaMS);
break;
}
// Always update confetti
this.updateConfetti(ticker.deltaMS);
}
// ============================================================================
// Galaxy Phase (Idle Sphere)
// ============================================================================
private updateGalaxy(deltaMs: number): void {
const centerX = this.app.screen.width / 2;
const centerY = this.app.screen.height / 2;
// Slowly rotate the sphere
this.sphereRotationY += deltaMs * 0.0003;
this.sphereRotationX = Math.sin(this.time * 0.0001) * 0.2;
this.nameParticles.forEach((p) => {
if (!p.text) return;
// Apply 3D rotation
const cosY = Math.cos(this.sphereRotationY);
const sinY = Math.sin(this.sphereRotationY);
const cosX = Math.cos(this.sphereRotationX);
const sinX = Math.sin(this.sphereRotationX);
// Rotate around Y axis
const x1 = (p.x - centerX) * cosY - p.z * sinY;
const z1 = (p.x - centerX) * sinY + p.z * cosY;
// Rotate around X axis
const y1 = (p.y - centerY) * cosX - z1 * sinX;
const z2 = (p.y - centerY) * sinX + z1 * cosX;
// Project to 2D with perspective
const perspective = 800;
const scale = perspective / (perspective + z2);
p.text.x = centerX + x1 * scale;
p.text.y = centerY + y1 * scale;
p.text.scale.set(scale * 0.8);
// Depth-based alpha and color
const depthAlpha = (z2 + this.sphereRadius) / (this.sphereRadius * 2);
p.text.alpha = p.isEligible ? 0.3 + depthAlpha * 0.7 : 0.15;
// Dim ineligible names
if (!p.isEligible) {
p.text.style.fill = 0x666666;
} else {
p.text.style.fill = COLORS.gold;
}
// Z-sorting
p.text.zIndex = Math.floor(z2);
});
this.galaxyContainer.sortChildren();
}
// ============================================================================
// Storm Phase (Tornado)
// ============================================================================
private updateStorm(deltaMs: number): void {
const centerX = this.app.screen.width / 2;
const centerY = this.app.screen.height / 2;
// Ramp up storm intensity
this.stormIntensity = Math.min(1, this.stormIntensity + deltaMs * 0.001);
this.stormAngle += deltaMs * 0.01 * this.stormIntensity;
// Apply motion blur based on intensity
this.blurFilter.strength = this.stormIntensity * 8;
this.nameParticles.forEach((p, index) => {
if (!p.text) return;
// Tornado vortex motion
const baseAngle = this.stormAngle + (index / this.nameParticles.length) * Math.PI * 2;
const verticalPos = ((this.time * 0.001 + index * 0.1) % 2) - 1; // -1 to 1
const radius = 100 + Math.abs(verticalPos) * 200 * this.stormIntensity;
const targetX = centerX + Math.cos(baseAngle) * radius;
const targetY = centerY + verticalPos * 300;
// Smooth interpolation
p.x += (targetX - p.x) * 0.1;
p.y += (targetY - p.y) * 0.1;
p.text.x = p.x;
p.text.y = p.y;
p.text.rotation += p.rotationSpeed * this.stormIntensity * 3;
p.text.alpha = p.isEligible ? 0.8 : 0.2;
// Scale based on position
const distFromCenter = Math.sqrt(
Math.pow(p.x - centerX, 2) + Math.pow(p.y - centerY, 2)
);
p.text.scale.set(0.5 + (1 - distFromCenter / 400) * 0.5);
});
}
// ============================================================================
// Reveal Phase
// ============================================================================
private updateReveal(deltaMs: number): void {
const centerX = this.app.screen.width / 2;
const centerY = this.app.screen.height / 2;
this.revealProgress = Math.min(1, this.revealProgress + deltaMs * 0.0008);
const eased = Easing.easeOutQuart(this.revealProgress);
// Reduce blur
this.blurFilter.strength = (1 - eased) * 8;
// Dim background
this.dimOverlay.clear();
this.dimOverlay.rect(0, 0, this.app.screen.width, this.app.screen.height);
this.dimOverlay.fill({ color: COLORS.black, alpha: eased * 0.7 });
// Disperse non-winners outward
this.nameParticles.forEach((p) => {
if (!p.text) return;
if (p.isWinner) {
// Winners fly to center
const targetX = centerX;
const targetY = centerY - 50;
p.x += (targetX - p.x) * 0.05 * eased;
p.y += (targetY - p.y) * 0.05 * eased;
p.text.alpha = 1;
p.text.scale.set(1 + eased * 1.5);
} else {
// Non-winners disperse
const angle = Math.atan2(p.y - centerY, p.x - centerX);
const disperseDistance = 500 * eased;
p.x += Math.cos(angle) * disperseDistance * 0.02;
p.y += Math.sin(angle) * disperseDistance * 0.02;
p.text.alpha = Math.max(0, 1 - eased * 1.5);
}
p.text.x = p.x;
p.text.y = p.y;
});
// Draw scroll background
if (this.revealProgress > 0.3) {
this.scrollScale = Easing.easeOutBack((this.revealProgress - 0.3) / 0.7);
this.drawScroll(centerX, centerY, this.scrollScale);
}
// Spawn confetti when reveal is complete
if (this.revealProgress >= 1 && this.phase === 'reveal') {
this.phase = 'complete';
this.spawnConfettiBurst();
this.createWinnerDisplay();
this.onWinnersRevealed?.(this.winners);
this.onPhaseChange?.('complete');
}
}
private drawScroll(centerX: number, centerY: number, scale: number): void {
this.scrollContainer.removeChildren();
const scrollGraphics = new Graphics();
const width = 600 * scale;
const height = 400 * scale;
// Main scroll body
scrollGraphics.roundRect(
centerX - width / 2,
centerY - height / 2,
width,
height,
20 * scale
);
scrollGraphics.fill({ color: COLORS.redDark, alpha: 0.95 });
// Gold border
scrollGraphics.roundRect(
centerX - width / 2 + 5,
centerY - height / 2 + 5,
width - 10,
height - 10,
15 * scale
);
scrollGraphics.stroke({ color: COLORS.gold, width: 3 * scale });
// Inner decorative border
scrollGraphics.roundRect(
centerX - width / 2 + 15,
centerY - height / 2 + 15,
width - 30,
height - 30,
10 * scale
);
scrollGraphics.stroke({ color: COLORS.goldDark, width: 1 * scale });
this.scrollContainer.addChild(scrollGraphics);
}
private createWinnerDisplay(): void {
this.revealContainer.removeChildren();
const centerX = this.app.screen.width / 2;
const centerY = this.app.screen.height / 2;
// Arrange winners
const winnerCount = this.winners.length;
const spacing = Math.min(120, 500 / winnerCount);
this.winners.forEach((winner, index) => {
const offsetX = (index - (winnerCount - 1) / 2) * spacing;
const text = new Text({
text: winner.name,
style: this.winnerStyle,
});
text.anchor.set(0.5);
text.x = centerX + offsetX;
text.y = centerY - 30;
// Department subtitle
const deptText = new Text({
text: winner.department,
style: new TextStyle({
fontFamily: '"Noto Serif SC", serif',
fontSize: 20,
fill: COLORS.goldDark,
}),
});
deptText.anchor.set(0.5);
deptText.x = centerX + offsetX;
deptText.y = centerY + 30;
this.revealContainer.addChild(text);
this.revealContainer.addChild(deptText);
});
}
// ============================================================================
// Complete Phase
// ============================================================================
private updateComplete(deltaMs: number): void {
// Gentle pulsing of winner names
const pulse = Math.sin(this.time * 0.003) * 0.05 + 1;
this.revealContainer.children.forEach((child, index) => {
if (child instanceof Text && index % 2 === 0) {
child.scale.set(pulse);
}
});
}
// ============================================================================
// Confetti System
// ============================================================================
private spawnConfettiBurst(): void {
const centerX = this.app.screen.width / 2;
const colors = [COLORS.gold, COLORS.goldLight, COLORS.red, COLORS.redLight];
for (let i = 0; i < 150; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 5 + Math.random() * 10;
this.confettiParticles.push({
x: centerX + (Math.random() - 0.5) * 200,
y: this.app.screen.height * 0.3,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 5,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.3,
width: 8 + Math.random() * 8,
height: 4 + Math.random() * 4,
color: colors[Math.floor(Math.random() * colors.length)],
alpha: 1,
gravity: 0.15 + Math.random() * 0.1,
});
}
}
private updateConfetti(deltaMs: number): void {
const confettiGraphics = new Graphics();
this.confettiContainer.removeChildren();
this.confettiParticles = this.confettiParticles.filter((p) => {
// Physics
p.vy += p.gravity;
p.vx *= 0.99; // Air resistance
p.x += p.vx;
p.y += p.vy;
p.rotation += p.rotationSpeed;
// Fade out when below screen
if (p.y > this.app.screen.height) {
p.alpha -= 0.02;
}
if (p.alpha <= 0) return false;
// Draw confetti piece (calculate rotated rectangle corners)
const cos = Math.cos(p.rotation);
const sin = Math.sin(p.rotation);
const hw = p.width / 2;
const hh = p.height / 2;
// Calculate rotated corners
const corners = [
{ x: -hw, y: -hh },
{ x: hw, y: -hh },
{ x: hw, y: hh },
{ x: -hw, y: hh },
].map(corner => ({
x: p.x + corner.x * cos - corner.y * sin,
y: p.y + corner.x * sin + corner.y * cos,
}));
// Draw as polygon
confettiGraphics.moveTo(corners[0].x, corners[0].y);
confettiGraphics.lineTo(corners[1].x, corners[1].y);
confettiGraphics.lineTo(corners[2].x, corners[2].y);
confettiGraphics.lineTo(corners[3].x, corners[3].y);
confettiGraphics.closePath();
confettiGraphics.fill({ color: p.color, alpha: p.alpha });
return true;
});
this.confettiContainer.addChild(confettiGraphics);
}
// ============================================================================
// Lifecycle
// ============================================================================
private handleResize(): void {
if (this.isDestroyed) return;
this.app.renderer.resize(window.innerWidth, window.innerHeight);
}
destroy(): void {
this.isDestroyed = true;
window.removeEventListener('resize', this.handleResize.bind(this));
this.nameParticles = [];
this.confettiParticles = [];
this.app.destroy(true, { children: true });
}
// ============================================================================
// Getters
// ============================================================================
get currentPhase(): LotteryPhase {
return this.phase;
}
get currentWinners(): Participant[] {
return this.winners;
}
}

View File

@@ -0,0 +1,492 @@
import { Application, Container, Graphics, Ticker } from 'pixi.js';
// ============================================================================
// Constants
// ============================================================================
const COLORS = {
gold: 0xf0c239,
goldLight: 0xffd700,
goldDark: 0xd4a84b,
red: 0xc21f30,
redLight: 0xe63946,
};
// Easing functions for smooth animation
const Easing = {
// Smooth acceleration then deceleration
easeInOutCubic: (t: number): number => {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
},
// Fast start, slow end
easeOutQuart: (t: number): number => {
return 1 - Math.pow(1 - t, 4);
},
// Slow start, fast end
easeInQuad: (t: number): number => {
return t * t;
},
// Bounce at end
easeOutBack: (t: number): number => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
};
// ============================================================================
// Types
// ============================================================================
interface VoteParticle {
x: number;
y: number;
targetX: number;
targetY: number;
startX: number;
startY: number;
controlX: number;
controlY: number;
progress: number;
speed: number;
size: number;
alpha: number;
glowSize: number;
trailPoints: Array<{ x: number; y: number; alpha: number }>;
phase: 'flying' | 'impact' | 'done';
impactTime: number;
}
interface ImpactEffect {
x: number;
y: number;
particles: Array<{
angle: number;
speed: number;
distance: number;
size: number;
alpha: number;
}>;
progress: number;
phase: 'burst' | 'fade';
}
interface TargetGlow {
x: number;
y: number;
intensity: number;
pulsePhase: number;
baseIntensity: number;
}
export interface ProgramTarget {
id: string;
x: number;
y: number;
name: string;
}
// ============================================================================
// VoteParticleSystem Class
// ============================================================================
export class VoteParticleSystem {
private app: Application;
private container: Container;
private particleLayer: Graphics;
private glowLayer: Graphics;
private impactLayer: Graphics;
private particles: VoteParticle[] = [];
private impacts: ImpactEffect[] = [];
private targetGlows: Map<string, TargetGlow> = new Map();
private programTargets: Map<string, ProgramTarget> = new Map();
private isDestroyed = false;
private time = 0;
// Configuration
private readonly MAX_PARTICLES = 200;
private readonly TRAIL_LENGTH = 12;
private readonly PARTICLE_DURATION = 1500; // ms
private readonly IMPACT_DURATION = 600; // ms
constructor() {
this.app = new Application();
this.container = new Container();
this.particleLayer = new Graphics();
this.glowLayer = new Graphics();
this.impactLayer = new Graphics();
}
async init(canvas: HTMLCanvasElement): Promise<void> {
await this.app.init({
canvas,
width: window.innerWidth,
height: window.innerHeight,
backgroundAlpha: 0,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
// Layer order: glow (back) -> particles -> impacts (front)
this.container.addChild(this.glowLayer);
this.container.addChild(this.particleLayer);
this.container.addChild(this.impactLayer);
this.app.stage.addChild(this.container);
// Start animation loop
this.app.ticker.add(this.update.bind(this));
// Handle resize
window.addEventListener('resize', this.handleResize.bind(this));
}
// ============================================================================
// Public API
// ============================================================================
/**
* Register a program target position for vote particles to fly towards
*/
registerTarget(target: ProgramTarget): void {
this.programTargets.set(target.id, target);
// Initialize glow state
if (!this.targetGlows.has(target.id)) {
this.targetGlows.set(target.id, {
x: target.x,
y: target.y,
intensity: 0,
pulsePhase: Math.random() * Math.PI * 2,
baseIntensity: 0,
});
}
}
/**
* Update target position (for responsive layouts)
*/
updateTargetPosition(id: string, x: number, y: number): void {
const target = this.programTargets.get(id);
if (target) {
target.x = x;
target.y = y;
}
const glow = this.targetGlows.get(id);
if (glow) {
glow.x = x;
glow.y = y;
}
}
/**
* Spawn a vote particle flying towards a program target
*/
spawnVoteParticle(targetId: string): void {
if (this.isDestroyed) return;
if (this.particles.length >= this.MAX_PARTICLES) return;
const target = this.programTargets.get(targetId);
if (!target) {
console.warn(`[VoteParticle] Unknown target: ${targetId}`);
return;
}
// Spawn from bottom edge with random horizontal position
const screenWidth = this.app.screen.width;
const screenHeight = this.app.screen.height;
const startX = Math.random() * screenWidth;
const startY = screenHeight + 20;
// Calculate bezier control point for curved trajectory
// Control point is offset to create an arc
const midX = (startX + target.x) / 2;
const midY = (startY + target.y) / 2;
const perpX = -(target.y - startY);
const perpY = target.x - startX;
const perpLen = Math.sqrt(perpX * perpX + perpY * perpY);
const curveAmount = (Math.random() - 0.5) * 300; // Random curve direction
const controlX = midX + (perpX / perpLen) * curveAmount;
const controlY = midY + (perpY / perpLen) * curveAmount;
const particle: VoteParticle = {
x: startX,
y: startY,
targetX: target.x,
targetY: target.y,
startX,
startY,
controlX,
controlY,
progress: 0,
speed: 0.8 + Math.random() * 0.4, // Vary speed slightly
size: 4 + Math.random() * 3,
alpha: 1,
glowSize: 15 + Math.random() * 10,
trailPoints: [],
phase: 'flying',
impactTime: 0,
};
this.particles.push(particle);
}
/**
* Spawn multiple particles at once (for burst effect)
*/
spawnVoteParticleBurst(targetId: string, count: number = 3): void {
for (let i = 0; i < count; i++) {
setTimeout(() => {
this.spawnVoteParticle(targetId);
}, i * 50); // Stagger spawns
}
}
/**
* Increase heat/glow intensity for a target
*/
addHeat(targetId: string, amount: number = 0.3): void {
const glow = this.targetGlows.get(targetId);
if (glow) {
glow.baseIntensity = Math.min(1, glow.baseIntensity + amount);
glow.intensity = Math.min(1.5, glow.intensity + amount * 2); // Pulse effect
}
}
// ============================================================================
// Animation Loop
// ============================================================================
private update(ticker: Ticker): void {
if (this.isDestroyed) return;
this.time += ticker.deltaMS;
// Clear all layers
this.particleLayer.clear();
this.glowLayer.clear();
this.impactLayer.clear();
// Update and draw target glows
this.updateTargetGlows(ticker.deltaMS);
// Update and draw particles
this.updateParticles(ticker.deltaMS);
// Update and draw impact effects
this.updateImpacts(ticker.deltaMS);
}
private updateTargetGlows(deltaMs: number): void {
this.targetGlows.forEach((glow, id) => {
// Decay intensity over time
glow.intensity = Math.max(glow.baseIntensity, glow.intensity - deltaMs * 0.002);
glow.baseIntensity = Math.max(0, glow.baseIntensity - deltaMs * 0.0002);
// Pulse animation
glow.pulsePhase += deltaMs * 0.003;
const pulse = Math.sin(glow.pulsePhase) * 0.1 + 1;
if (glow.intensity > 0.01) {
this.drawTargetGlow(glow, pulse);
}
});
}
private drawTargetGlow(glow: TargetGlow, pulse: number): void {
const intensity = glow.intensity * pulse;
const maxRadius = 60 + intensity * 40;
// Draw multiple concentric circles for glow effect
for (let i = 5; i >= 0; i--) {
const ratio = i / 5;
const radius = maxRadius * (0.3 + ratio * 0.7);
const alpha = intensity * (1 - ratio) * 0.4;
this.glowLayer.circle(glow.x, glow.y, radius);
this.glowLayer.fill({ color: COLORS.gold, alpha });
}
// Inner bright core
this.glowLayer.circle(glow.x, glow.y, 8 + intensity * 5);
this.glowLayer.fill({ color: COLORS.goldLight, alpha: intensity * 0.8 });
}
private updateParticles(deltaMs: number): void {
this.particles = this.particles.filter((p) => {
if (p.phase === 'flying') {
// Update progress along bezier curve
p.progress += (deltaMs / this.PARTICLE_DURATION) * p.speed;
if (p.progress >= 1) {
// Reached target - trigger impact
p.phase = 'impact';
p.impactTime = 0;
this.triggerImpact(p.targetX, p.targetY);
// Find target and add heat
for (const [id, target] of this.programTargets) {
if (target.x === p.targetX && target.y === p.targetY) {
this.addHeat(id);
break;
}
}
return false;
}
// Calculate position on quadratic bezier curve with easing
const t = Easing.easeInOutCubic(p.progress);
const invT = 1 - t;
p.x = invT * invT * p.startX + 2 * invT * t * p.controlX + t * t * p.targetX;
p.y = invT * invT * p.startY + 2 * invT * t * p.controlY + t * t * p.targetY;
// Update trail
p.trailPoints.unshift({ x: p.x, y: p.y, alpha: 1 });
if (p.trailPoints.length > this.TRAIL_LENGTH) {
p.trailPoints.pop();
}
// Fade trail points
p.trailPoints.forEach((point, i) => {
point.alpha = 1 - i / this.TRAIL_LENGTH;
});
// Draw particle
this.drawParticle(p);
return true;
}
return false;
});
}
private drawParticle(p: VoteParticle): void {
// Draw trail
p.trailPoints.forEach((point, i) => {
const size = p.size * (1 - i / this.TRAIL_LENGTH * 0.7);
const alpha = point.alpha * 0.6;
this.particleLayer.circle(point.x, point.y, size);
this.particleLayer.fill({ color: COLORS.gold, alpha });
});
// Draw glow around particle
for (let i = 3; i >= 0; i--) {
const ratio = i / 3;
const radius = p.glowSize * (0.3 + ratio * 0.7);
const alpha = (1 - ratio) * 0.3 * p.alpha;
this.particleLayer.circle(p.x, p.y, radius);
this.particleLayer.fill({ color: COLORS.gold, alpha });
}
// Draw bright core
this.particleLayer.circle(p.x, p.y, p.size);
this.particleLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
// Inner white hot center
this.particleLayer.circle(p.x, p.y, p.size * 0.5);
this.particleLayer.fill({ color: 0xffffff, alpha: p.alpha * 0.8 });
}
private triggerImpact(x: number, y: number): void {
const particleCount = 12 + Math.floor(Math.random() * 8);
const impactParticles: ImpactEffect['particles'] = [];
for (let i = 0; i < particleCount; i++) {
const angle = (Math.PI * 2 * i) / particleCount + Math.random() * 0.3;
impactParticles.push({
angle,
speed: 2 + Math.random() * 3,
distance: 0,
size: 2 + Math.random() * 3,
alpha: 1,
});
}
this.impacts.push({
x,
y,
particles: impactParticles,
progress: 0,
phase: 'burst',
});
}
private updateImpacts(deltaMs: number): void {
this.impacts = this.impacts.filter((impact) => {
impact.progress += deltaMs / this.IMPACT_DURATION;
if (impact.progress >= 1) {
return false;
}
// Update impact particles
const t = Easing.easeOutQuart(impact.progress);
impact.particles.forEach((p) => {
p.distance = t * 50 * p.speed;
p.alpha = 1 - Easing.easeInQuad(impact.progress);
p.size = p.size * (1 + t * 0.5);
});
// Draw impact
this.drawImpact(impact);
return true;
});
}
private drawImpact(impact: ImpactEffect): void {
// Draw central flash
const flashAlpha = (1 - impact.progress) * 0.8;
const flashSize = 20 + impact.progress * 30;
for (let i = 3; i >= 0; i--) {
const ratio = i / 3;
const radius = flashSize * (0.3 + ratio * 0.7);
const alpha = flashAlpha * (1 - ratio);
this.impactLayer.circle(impact.x, impact.y, radius);
this.impactLayer.fill({ color: COLORS.goldLight, alpha });
}
// Draw burst particles
impact.particles.forEach((p) => {
const px = impact.x + Math.cos(p.angle) * p.distance;
const py = impact.y + Math.sin(p.angle) * p.distance;
// Particle glow
this.impactLayer.circle(px, py, p.size * 2);
this.impactLayer.fill({ color: COLORS.gold, alpha: p.alpha * 0.3 });
// Particle core
this.impactLayer.circle(px, py, p.size);
this.impactLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
});
}
// ============================================================================
// Lifecycle
// ============================================================================
private handleResize(): void {
if (this.isDestroyed) return;
this.app.renderer.resize(window.innerWidth, window.innerHeight);
}
destroy(): void {
this.isDestroyed = true;
window.removeEventListener('resize', this.handleResize.bind(this));
this.particles = [];
this.impacts = [];
this.targetGlows.clear();
this.programTargets.clear();
this.app.destroy(true, { children: true });
}
}

View File

@@ -1,29 +1,96 @@
import { createRouter, createWebHistory } from 'vue-router';
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router';
// Admin auth constants
const ADMIN_TOKEN_KEY = 'gala_admin_token';
const ADMIN_ACCESS_CODE = '20268888';
// Auth guard for admin routes
function requireAdminAuth(to: RouteLocationNormalized) {
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
if (!token || token !== generateToken(ADMIN_ACCESS_CODE)) {
return { path: '/admin/login', query: { redirect: to.fullPath } };
}
return true;
}
// Simple token generator (not cryptographically secure, but sufficient for internal event)
function generateToken(code: string): string {
return btoa(`gala2026:${code}:${code.split('').reverse().join('')}`);
}
// Export for use in login component
export { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken };
const router = createRouter({
history: createWebHistory(),
routes: [
// ============================================
// Big Screen Display Routes (LED PC)
// ============================================
{
path: '/',
name: 'main',
name: 'screen-main',
component: () => import('../views/MainDisplay.vue'),
meta: { title: '年会大屏 - 主页' },
},
{
path: '/draw',
name: 'draw',
path: '/screen/voting',
name: 'screen-voting',
component: () => import('../views/LiveVotingView.vue'),
meta: { title: '年会大屏 - 实时投票' },
},
{
path: '/screen/draw',
name: 'screen-draw',
component: () => import('../views/LuckyDrawView.vue'),
meta: { title: '年会大屏 - 幸运抽奖' },
},
{
path: '/results',
name: 'results',
path: '/screen/results',
name: 'screen-results',
component: () => import('../views/VoteResultsView.vue'),
meta: { title: '年会大屏 - 投票结果' },
},
// Legacy routes (redirect to new paths)
{ path: '/voting', redirect: '/screen/voting' },
{ path: '/draw', redirect: '/screen/draw' },
{ path: '/results', redirect: '/screen/results' },
// ============================================
// Admin Routes (Director Console)
// ============================================
{
path: '/admin/login',
name: 'admin-login',
component: () => import('../views/AdminLogin.vue'),
meta: { title: '管理员登录' },
},
{
path: '/admin',
name: 'admin',
path: '/admin/director-console',
name: 'admin-console',
component: () => import('../views/AdminControl.vue'),
meta: { title: '导演控制台' },
beforeEnter: requireAdminAuth,
},
// Legacy admin route (redirect)
{ path: '/admin', redirect: '/admin/director-console' },
// ============================================
// 404 Catch-all
// ============================================
{
path: '/:pathMatch(.*)*',
name: 'not-found',
redirect: '/',
},
],
});
// Update document title on navigation
router.afterEach((to) => {
document.title = (to.meta.title as string) || '年会互动系统';
});
export default router;

View File

@@ -0,0 +1,391 @@
import { defineStore } from 'pinia';
import { ref, computed, shallowRef, watch } from 'vue';
import { io, Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
AdminState,
SystemPhase,
VotingControlPayload,
LotteryControlPayload,
EmergencyResetPayload,
MusicControlPayload,
LotteryRound,
} from '@gala/shared/types';
import { PRIZE_CONFIG } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
// Storage key for state persistence
const STORAGE_KEY = 'gala_admin_state';
export const useAdminStore = defineStore('admin', () => {
// Socket
const socket = shallowRef<GalaSocket | null>(null);
const isConnected = ref(false);
const isConnecting = ref(false);
const latency = ref(0);
const onlineUsers = ref(0);
// Admin State (mirrors server state)
const systemPhase = ref<SystemPhase>('IDLE');
const votingOpen = ref(false);
const votingPaused = ref(false);
const totalVotes = ref(0);
// Lottery State
const lotteryRound = ref<LotteryRound>(1);
const lotterySubPhase = ref<'IDLE' | 'GALAXY' | 'STORM' | 'REVEAL' | 'COMPLETE'>('IDLE');
const stormStartedAt = ref<number | null>(null);
const currentWinners = ref<Array<{ id: string; name: string; department: string }>>([]);
// Music State
const musicPlaying = ref(false);
const musicTrack = ref<'bgm' | 'lottery' | 'fanfare' | 'none'>('none');
// UI State
const pendingAction = ref<string | null>(null);
const lastError = ref<string | null>(null);
const lastActionTime = ref(0);
// Computed
const connectionStatus = computed(() => {
if (isConnected.value) return 'connected';
if (isConnecting.value) return 'connecting';
return 'disconnected';
});
const currentPrize = computed(() => {
const config = PRIZE_CONFIG.find(p => p.round === lotteryRound.value);
return config || PRIZE_CONFIG[0];
});
const canStopLottery = computed(() => {
if (lotterySubPhase.value !== 'STORM') return false;
if (!stormStartedAt.value) return false;
// 3-second safety delay
return Date.now() - stormStartedAt.value >= 3000;
});
const stopButtonCountdown = computed(() => {
if (lotterySubPhase.value !== 'STORM') return 0;
if (!stormStartedAt.value) return 3;
const elapsed = Date.now() - stormStartedAt.value;
return Math.max(0, Math.ceil((3000 - elapsed) / 1000));
});
// Persist state to localStorage
function saveState() {
const state = {
systemPhase: systemPhase.value,
votingOpen: votingOpen.value,
votingPaused: votingPaused.value,
lotteryRound: lotteryRound.value,
lotterySubPhase: lotterySubPhase.value,
currentWinners: currentWinners.value,
musicPlaying: musicPlaying.value,
musicTrack: musicTrack.value,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
// Restore state from localStorage
function restoreState() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const state = JSON.parse(saved);
systemPhase.value = state.systemPhase || 'IDLE';
votingOpen.value = state.votingOpen || false;
votingPaused.value = state.votingPaused || false;
lotteryRound.value = state.lotteryRound || 1;
lotterySubPhase.value = state.lotterySubPhase || 'IDLE';
currentWinners.value = state.currentWinners || [];
musicPlaying.value = state.musicPlaying || false;
musicTrack.value = state.musicTrack || 'none';
}
} catch (e) {
console.error('[Admin] Failed to restore state:', e);
}
}
// Watch for state changes and persist
watch(
[systemPhase, votingOpen, votingPaused, lotteryRound, lotterySubPhase, currentWinners, musicPlaying, musicTrack],
() => saveState(),
{ deep: true }
);
// Connect to server
function connect() {
if (socket.value?.connected || isConnecting.value) return;
isConnecting.value = true;
restoreState();
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 10000,
transports: ['websocket', 'polling'],
});
socketInstance.on('connect', () => {
console.log('[Admin] Connected');
isConnected.value = true;
isConnecting.value = false;
// Join as admin
socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, {
userId: 'admin_main',
userName: 'Admin Console',
role: 'admin',
}, () => {});
// Request state sync
socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, {});
});
socketInstance.on('disconnect', (reason) => {
console.log('[Admin] Disconnected:', reason);
isConnected.value = false;
});
socketInstance.on('connect_error', (error) => {
console.error('[Admin] Connection error:', error);
isConnecting.value = false;
});
// User count updates
socketInstance.on('connection:users_count' as any, (count: number) => {
onlineUsers.value = count;
});
// Admin state sync from server
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
syncFromServer(state);
});
socket.value = socketInstance as GalaSocket;
}
function syncFromServer(state: AdminState) {
systemPhase.value = state.systemPhase;
votingOpen.value = state.voting.subPhase === 'OPEN';
votingPaused.value = state.voting.subPhase === 'PAUSED';
totalVotes.value = state.voting.totalVotes;
lotteryRound.value = state.lottery.round;
lotterySubPhase.value = state.lottery.subPhase;
currentWinners.value = state.lottery.currentWinners;
musicPlaying.value = state.music.isPlaying;
musicTrack.value = state.music.track;
saveState();
}
function disconnect() {
if (socket.value) {
socket.value.disconnect();
socket.value = null;
}
isConnected.value = false;
isConnecting.value = false;
}
// Admin Actions
function setPhase(phase: SystemPhase) {
if (!socket.value?.connected) return;
pendingAction.value = 'phase_change';
socket.value.emit(SOCKET_EVENTS.ADMIN_PHASE_CHANGE as any, {
phase,
timestamp: Date.now(),
}, (response: any) => {
pendingAction.value = null;
if (response.success) {
systemPhase.value = phase;
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || 'Failed to change phase';
}
});
}
function controlVoting(action: 'open' | 'close' | 'pause' | 'resume') {
if (!socket.value?.connected) return;
pendingAction.value = `voting_${action}`;
socket.value.emit(SOCKET_EVENTS.ADMIN_VOTING_CONTROL as any, {
action,
timestamp: Date.now(),
} as VotingControlPayload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (action === 'open') {
votingOpen.value = true;
votingPaused.value = false;
} else if (action === 'close') {
votingOpen.value = false;
votingPaused.value = false;
} else if (action === 'pause') {
votingPaused.value = true;
} else if (action === 'resume') {
votingPaused.value = false;
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || `Failed to ${action} voting`;
}
});
}
function controlLottery(action: LotteryControlPayload['action'], winnerIds?: string[]) {
if (!socket.value?.connected) return;
pendingAction.value = `lottery_${action}`;
const payload: LotteryControlPayload = {
action,
round: lotteryRound.value,
timestamp: Date.now(),
};
if (winnerIds) payload.winnerIds = winnerIds;
socket.value.emit(SOCKET_EVENTS.ADMIN_LOTTERY_CONTROL as any, payload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (action === 'start_galaxy') {
lotterySubPhase.value = 'GALAXY';
} else if (action === 'start_storm') {
lotterySubPhase.value = 'STORM';
stormStartedAt.value = Date.now();
} else if (action === 'stop_reveal') {
lotterySubPhase.value = 'REVEAL';
if (response.data?.winners) {
currentWinners.value = response.data.winners;
}
} else if (action === 'next_round') {
lotteryRound.value = Math.min(4, lotteryRound.value + 1) as LotteryRound;
lotterySubPhase.value = 'IDLE';
currentWinners.value = [];
stormStartedAt.value = null;
} else if (action === 'reset') {
lotteryRound.value = 1;
lotterySubPhase.value = 'IDLE';
currentWinners.value = [];
stormStartedAt.value = null;
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || `Failed to ${action}`;
}
});
}
function emergencyReset(scope: 'all' | 'voting' | 'lottery', confirmCode: string) {
if (!socket.value?.connected) return;
if (confirmCode !== 'RESET') {
lastError.value = 'Invalid confirmation code';
return;
}
pendingAction.value = 'emergency_reset';
socket.value.emit(SOCKET_EVENTS.ADMIN_EMERGENCY_RESET as any, {
scope,
confirmCode,
timestamp: Date.now(),
} as EmergencyResetPayload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (scope === 'all' || scope === 'voting') {
votingOpen.value = false;
votingPaused.value = false;
totalVotes.value = 0;
}
if (scope === 'all' || scope === 'lottery') {
lotteryRound.value = 1;
lotterySubPhase.value = 'IDLE';
currentWinners.value = [];
stormStartedAt.value = null;
}
if (scope === 'all') {
systemPhase.value = 'IDLE';
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || 'Emergency reset failed';
}
});
}
function controlMusic(action: 'play' | 'stop' | 'change_track', track?: MusicControlPayload['track']) {
if (!socket.value?.connected) return;
pendingAction.value = `music_${action}`;
socket.value.emit(SOCKET_EVENTS.ADMIN_MUSIC_CONTROL as any, {
action,
track,
} as MusicControlPayload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (action === 'play') {
musicPlaying.value = true;
if (track) musicTrack.value = track;
} else if (action === 'stop') {
musicPlaying.value = false;
} else if (action === 'change_track' && track) {
musicTrack.value = track;
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || `Failed to ${action} music`;
}
});
}
function clearError() {
lastError.value = null;
}
return {
// Connection
isConnected,
isConnecting,
connectionStatus,
latency,
onlineUsers,
// State
systemPhase,
votingOpen,
votingPaused,
totalVotes,
lotteryRound,
lotterySubPhase,
stormStartedAt,
currentWinners,
musicPlaying,
musicTrack,
// Computed
currentPrize,
canStopLottery,
stopButtonCountdown,
// UI State
pendingAction,
lastError,
lastActionTime,
// Actions
connect,
disconnect,
setPhase,
controlVoting,
controlLottery,
emergencyReset,
controlMusic,
clearError,
};
});

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { ref, computed, shallowRef } from 'vue';
import { ref, computed, shallowRef, watch } from 'vue';
import { io, Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
@@ -7,6 +7,8 @@ import type {
DrawStartPayload,
DrawWinnerPayload,
VoteUpdatePayload,
AdminState,
SystemPhase,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
@@ -67,7 +69,7 @@ export const useDisplayStore = defineStore('display', () => {
userId: 'screen_main',
userName: 'Main Display',
role: 'screen',
}, () => {});
}, () => { });
});
socketInstance.on('disconnect', (reason) => {
@@ -107,6 +109,28 @@ export const useDisplayStore = defineStore('display', () => {
window.dispatchEvent(new CustomEvent('vote:updated', { detail: data }));
});
// Admin state sync - listen for phase changes from director console
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
console.log('[Screen] Admin state sync received:', state.systemPhase);
// Map SystemPhase to display mode
const phaseToMode: Record<SystemPhase, 'idle' | 'voting' | 'draw' | 'results'> = {
'IDLE': 'idle',
'VOTING': 'voting',
'LOTTERY': 'draw',
'RESULTS': 'results',
};
const newMode = phaseToMode[state.systemPhase] || 'idle';
if (currentMode.value !== newMode) {
currentMode.value = newMode;
// Emit custom event for App.vue to handle route navigation
window.dispatchEvent(new CustomEvent('screen:mode_change', {
detail: { mode: newMode, phase: state.systemPhase }
}));
}
});
socket.value = socketInstance as GalaSocket;
}

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

View File

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