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