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:
42
packages/shared/src/constants/draw.ts
Normal file
42
packages/shared/src/constants/draw.ts
Normal 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;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './events';
|
||||
export * from './errors';
|
||||
export * from './config';
|
||||
export * from './voting';
|
||||
export * from './draw';
|
||||
|
||||
68
packages/shared/src/constants/voting.ts
Normal file
68
packages/shared/src/constants/voting.ts
Normal 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;
|
||||
143
packages/shared/src/types/admin.types.ts
Normal file
143
packages/shared/src/types/admin.types.ts
Normal 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(),
|
||||
};
|
||||
@@ -2,3 +2,4 @@
|
||||
export * from './socket.types';
|
||||
export * from './vote.types';
|
||||
export * from './draw.types';
|
||||
export * from './admin.types';
|
||||
|
||||
Reference in New Issue
Block a user