feat: add Admin Control Panel, voting status check, and router security

Admin Control Panel:
- Add full AdminControl.vue with 3 sections (Voting, Lottery, Global)
- Add AdminLogin.vue with access code gate (20268888)
- Add admin.ts store with state persistence
- Add admin.types.ts with state machine types
- Add router guards for /admin/director-console

Voting System Fixes:
- Add voting status check before accepting votes (VOTING_CLOSED error)
- Fix client to display server error messages
- Fix button disabled logic to prevent ambiguity in paused state
- Auto-generate userId on connect to fix UNAUTHORIZED error

Big Screen Enhancements:
- Add LiveVotingView.vue with particle system
- Add LotteryMachine.ts with 3-stage animation (Galaxy/Storm/Reveal)
- Add useSocketClient.ts composable
- Fix MainDisplay.vue SCSS syntax error
- Add admin state sync listener in display store

Server Updates:
- Add admin.service.ts for state management
- Add isVotingOpen() and getVotingStatus() methods
- Add admin socket event handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-15 15:34:37 +08:00
parent e7397d22a9
commit 30cd29d45d
45 changed files with 7791 additions and 715 deletions

View File

@@ -0,0 +1,42 @@
// ============================================================================
// Draw Pool Constants
// ============================================================================
/**
* Generation tags based on birth year
*/
export const GENERATION_TAGS = {
TAG_6070: '6070', // 1960-1979
TAG_80: '80', // 1980-1989
TAG_90: '90', // 1990+
} as const;
/**
* Zodiac tag for Year of the Horse
*/
export const ZODIAC_TAGS = {
HORSE: 'horse',
} as const;
export type GenerationTag = typeof GENERATION_TAGS[keyof typeof GENERATION_TAGS];
export type ZodiacTag = typeof ZODIAC_TAGS[keyof typeof ZODIAC_TAGS];
export type UserTag = GenerationTag | ZodiacTag;
/**
* Redis Key Schema for Lucky Draw
*
* draw:pool:{tag} → Set of userIds in this pool
* draw:pool:all → Set of all eligible userIds
* draw:winners → Set of userIds who have won
* draw:winners:{round} → Set of winners for specific round
* draw:history → List of draw results
* draw:excluded → Set of manually excluded userIds
*/
export const DRAW_KEYS = {
pool: (tag: string) => `draw:pool:${tag}`,
allPool: 'draw:pool:all',
winners: 'draw:winners',
roundWinners: (round: number) => `draw:winners:round:${round}`,
history: 'draw:history',
excluded: 'draw:excluded',
} as const;

View File

@@ -26,6 +26,14 @@ export const SOCKET_EVENTS = {
// Sync events
SYNC_REQUEST: 'sync:request',
SYNC_STATE: 'sync:state',
// Admin control events
ADMIN_PHASE_CHANGE: 'admin:phase_change',
ADMIN_VOTING_CONTROL: 'admin:voting_control',
ADMIN_LOTTERY_CONTROL: 'admin:lottery_control',
ADMIN_EMERGENCY_RESET: 'admin:emergency_reset',
ADMIN_STATE_SYNC: 'admin:state_sync',
ADMIN_MUSIC_CONTROL: 'admin:music_control',
} as const;
export const SOCKET_ROOMS = {

View File

@@ -1,3 +1,5 @@
export * from './events';
export * from './errors';
export * from './config';
export * from './voting';
export * from './draw';

View File

@@ -0,0 +1,68 @@
// ============================================================================
// 7-Ticket Voting System Constants
// ============================================================================
/**
* The 7 distinct ticket types (awards)
* Each user has exactly one of each ticket type
*/
export const TICKET_TYPES = [
'creative', // 最佳创意奖
'visual', // 最佳视觉奖
'atmosphere', // 最佳气氛奖
'performance', // 最佳表演奖
'teamwork', // 最佳团队奖
'popularity', // 最受欢迎奖
'potential', // 最具潜力奖
] as const;
export type TicketType = typeof TICKET_TYPES[number];
/**
* Redis Key Schema for Voting
*
* vote:count:{programId}:{ticketType} → String (atomic counter)
* vote:user:{userId}:tickets → Hash { ticketType -> programId }
* vote:user:{userId}:programs → Set of programIds (prevent multi-ticket to same program)
* vote:program:{programId}:voters → Set of userIds
* vote:leaderboard:{ticketType} → Sorted Set { programId -> score }
* vote:sync:queue → List (for MySQL sync)
* vote:lock:{userId} → String (distributed lock)
*/
export const VOTE_KEYS = {
// Count of votes for a program in a specific ticket type
count: (programId: string, ticketType: TicketType) =>
`vote:count:${programId}:${ticketType}`,
// User's ticket assignments: { ticketType -> programId }
userTickets: (userId: string) =>
`vote:user:${userId}:tickets`,
// Set of programs a user has voted for (to prevent multi-ticket to same program)
userPrograms: (userId: string) =>
`vote:user:${userId}:programs`,
// Set of users who voted for a program
programVoters: (programId: string) =>
`vote:program:${programId}:voters`,
// Leaderboard for each ticket type
leaderboard: (ticketType: TicketType) =>
`vote:leaderboard:${ticketType}`,
// Sync queue for MySQL persistence
syncQueue: 'vote:sync:queue',
// Distributed lock for a user's voting operation
lock: (userId: string) =>
`vote:lock:${userId}`,
} as const;
/**
* Voting configuration
*/
export const VOTE_CONFIG = {
LOCK_TTL_MS: 5000, // Lock timeout
MAX_TICKETS: 7, // Total tickets per user
SESSION_ACTIVE: true, // Whether voting is open
} as const;

View File

@@ -0,0 +1,143 @@
// Admin Control Panel Types - State Machine & Control Events
// ============================================================================
// System Phase State Machine
// ============================================================================
export type SystemPhase = 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS';
export type VotingSubPhase = 'CLOSED' | 'OPEN' | 'PAUSED';
export type LotterySubPhase =
| 'IDLE' // Waiting to start
| 'GALAXY' // Showing participants
| 'STORM' // Rolling animation
| 'REVEAL' // Revealing winners
| 'COMPLETE'; // Winners displayed
export type LotteryRound = 1 | 2 | 3 | 4;
// ============================================================================
// Admin State
// ============================================================================
export interface AdminState {
systemPhase: SystemPhase;
voting: VotingState;
lottery: LotteryState;
music: MusicState;
lastUpdated: number;
}
export interface VotingState {
subPhase: VotingSubPhase;
openedAt?: number;
closedAt?: number;
totalVotes: number;
}
export interface LotteryState {
round: LotteryRound;
subPhase: LotterySubPhase;
prizeLevel: string;
prizeName: string;
winnerCount: number;
currentWinners: LotteryWinner[];
startedAt?: number;
stormStartedAt?: number; // For safety delay calculation
}
export interface LotteryWinner {
id: string;
name: string;
department: string;
}
export interface MusicState {
isPlaying: boolean;
track: 'bgm' | 'lottery' | 'fanfare' | 'none';
volume: number;
}
// ============================================================================
// Admin Control Payloads
// ============================================================================
export interface PhaseChangePayload {
phase: SystemPhase;
timestamp: number;
}
export interface VotingControlPayload {
action: 'open' | 'close' | 'pause' | 'resume';
timestamp: number;
}
export interface LotteryControlPayload {
action: 'start_galaxy' | 'start_storm' | 'stop_reveal' | 'next_round' | 'reset';
round?: LotteryRound;
winnerIds?: string[]; // For reveal action
timestamp: number;
}
export interface EmergencyResetPayload {
scope: 'all' | 'voting' | 'lottery';
confirmCode: string; // Safety: require confirmation code
timestamp: number;
}
export interface MusicControlPayload {
action: 'play' | 'stop' | 'change_track';
track?: MusicState['track'];
volume?: number;
}
export interface AdminStateSyncPayload {
state: AdminState;
requestedBy?: string;
}
// ============================================================================
// Prize Configuration
// ============================================================================
export interface PrizeConfig {
round: LotteryRound;
level: string;
name: string;
winnerCount: number;
zodiacFilter?: string; // Round 4: 'horse'
}
export const PRIZE_CONFIG: PrizeConfig[] = [
{ round: 1, level: '特等奖', name: 'iPhone 16 Pro Max', winnerCount: 1 },
{ round: 2, level: '一等奖', name: 'iPad Pro', winnerCount: 3 },
{ round: 3, level: '二等奖', name: 'AirPods Pro', winnerCount: 5 },
{ round: 4, level: '三等奖', name: '京东卡 500元', winnerCount: 10, zodiacFilter: 'horse' },
];
// ============================================================================
// Initial State
// ============================================================================
export const INITIAL_ADMIN_STATE: AdminState = {
systemPhase: 'IDLE',
voting: {
subPhase: 'CLOSED',
totalVotes: 0,
},
lottery: {
round: 1,
subPhase: 'IDLE',
prizeLevel: '特等奖',
prizeName: 'iPhone 16 Pro Max',
winnerCount: 1,
currentWinners: [],
},
music: {
isPlaying: false,
track: 'none',
volume: 0.7,
},
lastUpdated: Date.now(),
};

View File

@@ -2,3 +2,4 @@
export * from './socket.types';
export * from './vote.types';
export * from './draw.types';
export * from './admin.types';