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:
empty
2026-01-16 15:15:17 +08:00
parent 30cd29d45d
commit 84be8c4b5c
19 changed files with 2056 additions and 382 deletions

View File

@@ -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:*');

View File

@@ -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 });
}
}