feat: redesign Big Screen voting view with philatelic postcard UI
- Add PostcardItem.vue component with Chinese postal aesthetics - Add PostcardGrid.vue container with 4x2 CSS Grid layout - Add Postmark.vue component for real-time vote stamp visualization - Update LiveVotingView.vue with cream paper theme (#FDFBF7) - Add Year of the Horse 2026 stamp image - Add responsive breakpoints for different screen sizes - Enhance admin service with program voting control - Add vote stamp accumulation for big screen display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,10 @@ import type {
|
||||
MusicControlPayload,
|
||||
LotteryRound,
|
||||
LotteryWinner,
|
||||
VotingProgram,
|
||||
VoteStamp,
|
||||
} from '@gala/shared/types';
|
||||
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG } from '@gala/shared/types';
|
||||
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG, DEFAULT_PROGRAMS } from '@gala/shared/types';
|
||||
|
||||
const ADMIN_STATE_KEY = 'gala:admin:state';
|
||||
|
||||
@@ -31,8 +33,29 @@ class AdminService {
|
||||
try {
|
||||
const savedState = await redis.get(ADMIN_STATE_KEY);
|
||||
if (savedState) {
|
||||
this.state = JSON.parse(savedState);
|
||||
logger.info('Admin state restored from Redis');
|
||||
const parsed = JSON.parse(savedState);
|
||||
// Deep merge to ensure new fields have defaults
|
||||
this.state = {
|
||||
...INITIAL_ADMIN_STATE,
|
||||
...parsed,
|
||||
voting: {
|
||||
...INITIAL_ADMIN_STATE.voting,
|
||||
...parsed.voting,
|
||||
// Ensure programs always has default values
|
||||
programs: parsed.voting?.programs?.length > 0
|
||||
? parsed.voting.programs
|
||||
: DEFAULT_PROGRAMS,
|
||||
},
|
||||
lottery: {
|
||||
...INITIAL_ADMIN_STATE.lottery,
|
||||
...parsed.lottery,
|
||||
},
|
||||
music: {
|
||||
...INITIAL_ADMIN_STATE.music,
|
||||
...parsed.music,
|
||||
},
|
||||
};
|
||||
logger.info('Admin state restored from Redis (merged with defaults)');
|
||||
} else {
|
||||
await this.saveState();
|
||||
logger.info('Admin state initialized with defaults');
|
||||
@@ -135,6 +158,169 @@ class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Program Voting Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start voting for a specific program by ID
|
||||
*/
|
||||
async startProgramVoting(programId: string): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const programs = this.state.voting.programs;
|
||||
const targetProgram = programs.find(p => p.id === programId);
|
||||
|
||||
if (!targetProgram) {
|
||||
return { success: false, message: 'Program not found' };
|
||||
}
|
||||
|
||||
// Mark previous current program as completed
|
||||
if (this.state.voting.currentProgramId) {
|
||||
const prevProgram = programs.find(p => p.id === this.state.voting.currentProgramId);
|
||||
if (prevProgram && prevProgram.status === 'voting') {
|
||||
prevProgram.status = 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
// Set new current program
|
||||
targetProgram.status = 'voting';
|
||||
this.state.voting.currentProgramId = programId;
|
||||
this.state.voting.currentProgramIndex = programs.indexOf(targetProgram);
|
||||
this.state.voting.votingStartedAt = Date.now();
|
||||
|
||||
await this.saveState();
|
||||
logger.info({ programId, programName: targetProgram.name }, 'Program voting started');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, programId }, 'Failed to start program voting');
|
||||
return { success: false, message: 'Failed to start program voting' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to next program in order
|
||||
*/
|
||||
async nextProgram(): Promise<{ success: boolean; message?: string; programId?: string }> {
|
||||
try {
|
||||
const programs = this.state.voting.programs;
|
||||
|
||||
// Find next pending program
|
||||
const nextProgram = programs.find(p => p.status === 'pending');
|
||||
|
||||
if (!nextProgram) {
|
||||
return { success: false, message: 'No more programs to vote' };
|
||||
}
|
||||
|
||||
const result = await this.startProgramVoting(nextProgram.id);
|
||||
if (result.success) {
|
||||
return { success: true, programId: nextProgram.id };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to move to next program');
|
||||
return { success: false, message: 'Failed to move to next program' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder programs (only pending ones can be reordered)
|
||||
*/
|
||||
async reorderPrograms(programIds: string[]): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const programs = this.state.voting.programs;
|
||||
|
||||
// Validate all IDs exist
|
||||
for (const id of programIds) {
|
||||
if (!programs.find(p => p.id === id)) {
|
||||
return { success: false, message: `Program ${id} not found` };
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder: completed/voting first, then pending in new order
|
||||
const completed = programs.filter(p => p.status === 'completed' || p.status === 'voting');
|
||||
const pending = programIds
|
||||
.filter(id => programs.find(p => p.id === id)?.status === 'pending')
|
||||
.map(id => programs.find(p => p.id === id)!);
|
||||
|
||||
this.state.voting.programs = [...completed, ...pending];
|
||||
|
||||
await this.saveState();
|
||||
logger.info({ newOrder: programIds }, 'Programs reordered');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to reorder programs');
|
||||
return { success: false, message: 'Failed to reorder programs' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle late catch (补投票) setting
|
||||
*/
|
||||
async toggleLateCatch(enabled: boolean): Promise<{ success: boolean }> {
|
||||
this.state.voting.allowLateCatch = enabled;
|
||||
await this.saveState();
|
||||
logger.info({ allowLateCatch: enabled }, 'Late catch setting changed');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a vote stamp to a program (for display on big screen)
|
||||
*/
|
||||
async addVoteStamp(programId: string, userName: string, department: string, ticketType: string): Promise<{ success: boolean; stamp?: VoteStamp }> {
|
||||
const program = this.state.voting.programs.find(p => p.id === programId);
|
||||
if (!program) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const stamp: VoteStamp = {
|
||||
id: `stamp_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
|
||||
userName,
|
||||
department,
|
||||
ticketType,
|
||||
x: 10 + Math.random() * 80, // Random X position (10-90%)
|
||||
y: 10 + Math.random() * 80, // Random Y position (10-90%)
|
||||
rotation: -30 + Math.random() * 60, // Random rotation (-30 to +30 degrees)
|
||||
timestamp: Date.now(),
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (!program.stamps) program.stamps = [];
|
||||
program.stamps.push(stamp);
|
||||
program.votes++;
|
||||
this.state.voting.totalVotes++;
|
||||
|
||||
await this.saveState();
|
||||
return { success: true, stamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can vote for a program
|
||||
*/
|
||||
canVoteForProgram(programId: string): { canVote: boolean; reason?: string } {
|
||||
const program = this.state.voting.programs.find(p => p.id === programId);
|
||||
if (!program) {
|
||||
return { canVote: false, reason: '节目不存在' };
|
||||
}
|
||||
|
||||
if (this.state.voting.subPhase !== 'OPEN') {
|
||||
return { canVote: false, reason: '投票通道未开放' };
|
||||
}
|
||||
|
||||
if (program.status === 'pending') {
|
||||
return { canVote: false, reason: '该节目投票尚未开始' };
|
||||
}
|
||||
|
||||
if (program.status === 'completed' && !this.state.voting.allowLateCatch) {
|
||||
return { canVote: false, reason: '该节目投票已结束' };
|
||||
}
|
||||
|
||||
// Can vote for 'voting' status, or 'completed' if allowLateCatch is true
|
||||
return { canVote: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Control lottery state machine
|
||||
*/
|
||||
@@ -222,7 +408,7 @@ class AdminService {
|
||||
private pickRandomWinners(): LotteryWinner[] {
|
||||
const prizeConfig = PRIZE_CONFIG.find(p => p.round === this.state.lottery.round);
|
||||
const count = prizeConfig?.winnerCount || 1;
|
||||
|
||||
|
||||
// Demo winners for testing
|
||||
const demoNames = [
|
||||
{ id: 'w1', name: '张明', department: '技术部' },
|
||||
@@ -236,7 +422,7 @@ class AdminService {
|
||||
{ id: 'w9', name: '郑雪', department: '技术部' },
|
||||
{ id: 'w10', name: '孙浩', department: '市场部' },
|
||||
];
|
||||
|
||||
|
||||
// Shuffle and pick
|
||||
const shuffled = [...demoNames].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count);
|
||||
@@ -256,8 +442,8 @@ class AdminService {
|
||||
try {
|
||||
if (scope === 'all' || scope === 'voting') {
|
||||
this.state.voting = {
|
||||
subPhase: 'CLOSED',
|
||||
totalVotes: 0,
|
||||
...INITIAL_ADMIN_STATE.voting,
|
||||
programs: DEFAULT_PROGRAMS.map(p => ({ ...p, votes: 0, stamps: [] })),
|
||||
};
|
||||
// Clear voting data in Redis
|
||||
await redis.del('gala:votes:*');
|
||||
|
||||
@@ -115,6 +115,19 @@ function handleConnection(socket: GalaSocket): void {
|
||||
handleAdminStateSync(socket);
|
||||
});
|
||||
|
||||
// Program voting control events
|
||||
socket.on('admin:start_program' as any, (data: { programId: string }, callback: AckCallback<void>) => {
|
||||
handleStartProgram(socket, data, callback);
|
||||
});
|
||||
|
||||
socket.on('admin:next_program' as any, (data: any, callback: AckCallback<void>) => {
|
||||
handleNextProgram(socket, callback);
|
||||
});
|
||||
|
||||
socket.on('admin:toggle_late_catch' as any, (data: { enabled: boolean }, callback: AckCallback<void>) => {
|
||||
handleToggleLateCatch(socket, data, callback);
|
||||
});
|
||||
|
||||
// Disconnect handler
|
||||
socket.on('disconnect', (reason) => {
|
||||
handleDisconnect(socket, reason);
|
||||
@@ -135,11 +148,12 @@ async function handleJoin(
|
||||
callback: AckCallback<ConnectionAckPayload>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { userId, userName, role } = data;
|
||||
const { userId, userName, role, department } = data;
|
||||
|
||||
// Store user data in socket
|
||||
socket.data.userId = userId;
|
||||
socket.data.userName = userName;
|
||||
socket.data.department = department || '未知部门';
|
||||
socket.data.role = role;
|
||||
socket.data.connectedAt = new Date();
|
||||
socket.data.sessionId = socket.id;
|
||||
@@ -235,12 +249,21 @@ async function handleVoteSubmit(
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast vote update to all clients
|
||||
// Add a vote stamp for the big screen display
|
||||
const stampResult = await adminService.addVoteStamp(
|
||||
data.candidateId,
|
||||
socket.data.userName || '匿名用户',
|
||||
socket.data.department || '未知部门',
|
||||
category
|
||||
);
|
||||
|
||||
// Broadcast vote update to all clients with stamp info
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
|
||||
candidateId: data.candidateId,
|
||||
category: category,
|
||||
totalVotes: result.new_count!,
|
||||
delta: 1,
|
||||
stamp: stampResult.stamp, // Pass the stamp info for animation
|
||||
});
|
||||
|
||||
callback({
|
||||
@@ -267,12 +290,21 @@ async function handleVoteSubmit(
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast vote update to all clients
|
||||
// Add a vote stamp for the big screen display (legacy category system)
|
||||
const stampResult = await adminService.addVoteStamp(
|
||||
data.candidateId,
|
||||
socket.data.userName || '匿名用户',
|
||||
socket.data.department || '未知部门',
|
||||
data.category as string
|
||||
);
|
||||
|
||||
// Broadcast vote update to all clients with stamp info
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
|
||||
candidateId: data.candidateId,
|
||||
category: data.category,
|
||||
totalVotes: result.new_count!,
|
||||
delta: 1,
|
||||
stamp: stampResult.stamp, // Include stamp for big screen
|
||||
});
|
||||
|
||||
callback({
|
||||
@@ -476,3 +508,76 @@ async function handleEmergencyReset(
|
||||
function handleAdminStateSync(socket: GalaSocket): void {
|
||||
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Program Voting Control Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle start program voting
|
||||
*/
|
||||
async function handleStartProgram(
|
||||
socket: GalaSocket,
|
||||
data: { programId: string },
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.startProgramVoting(data.programId);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle next program
|
||||
*/
|
||||
async function handleNextProgram(
|
||||
socket: GalaSocket,
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.nextProgram();
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle late catch voting
|
||||
*/
|
||||
async function handleToggleLateCatch(
|
||||
socket: GalaSocket,
|
||||
data: { enabled: boolean },
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.toggleLateCatch(data.enabled);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user