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

@@ -7,6 +7,7 @@ import type {
ConnectionAckPayload,
VoteCategory,
SyncStatePayload,
AdminState,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
import { CONFIG } from '@gala/shared/constants';
@@ -24,6 +25,7 @@ export const useConnectionStore = defineStore('connection', () => {
const reconnectAttempts = ref(0);
const userId = ref<string | null>(null);
const userName = ref<string | null>(null);
const department = ref<string | null>(null);
const votedCategories = ref<VoteCategory[]>([]);
// Computed
@@ -116,6 +118,29 @@ export const useConnectionStore = defineStore('connection', () => {
votedCategories.value = data.userVotedCategories;
}
});
// Admin state sync - update voting state
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
console.log('[Socket] Admin state sync received');
// Dynamically import voting store to avoid circular dependency
import('./voting').then(({ useVotingStore }) => {
const votingStore = useVotingStore();
votingStore.syncVotingState({
votingOpen: state.voting.subPhase === 'OPEN',
votingPaused: state.voting.subPhase === 'PAUSED',
programs: state.voting.programs,
currentProgramId: state.voting.currentProgramId,
allowLateCatch: state.voting.allowLateCatch,
});
});
});
// Remote vote update (from other users)
socketInstance.on(SOCKET_EVENTS.VOTE_UPDATED as any, (data: any) => {
import('./voting').then(({ useVotingStore }) => {
const votingStore = useVotingStore();
votingStore.handleVoteUpdate(data);
});
});
socket.value = socketInstance as GalaSocket;
}
@@ -131,6 +156,7 @@ export const useConnectionStore = defineStore('connection', () => {
{
userId: userId.value,
userName: userName.value || 'Guest',
department: department.value || '未知部门',
role: 'user',
},
(response: any) => {
@@ -193,9 +219,10 @@ export const useConnectionStore = defineStore('connection', () => {
/**
* Set user info
*/
function setUser(id: string, name: string) {
function setUser(id: string, name: string, dept: string) {
userId.value = id;
userName.value = name;
department.value = dept;
// Rejoin if already connected
if (socket.value?.connected) {
@@ -237,6 +264,7 @@ export const useConnectionStore = defineStore('connection', () => {
reconnectAttempts,
userId,
userName,
department,
votedCategories,
// Computed

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useConnectionStore } from './connection';
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
import { showToast } from 'vant';
import type { VoteStamp } from '@gala/shared/types';
// Ticket display info
export const TICKET_INFO: Record<TicketType, { name: string; icon: string }> = {
@@ -21,6 +22,15 @@ interface PendingVote {
timestamp: number;
}
interface VotingProgram {
id: string;
name: string;
teamName: string;
order: number;
status: 'pending' | 'voting' | 'completed';
votes: number;
}
export const useVotingStore = defineStore('voting', () => {
const connectionStore = useConnectionStore();
@@ -35,6 +45,13 @@ export const useVotingStore = defineStore('voting', () => {
potential: null,
});
// Server-synced voting state
const votingOpen = ref(false);
const votingPaused = ref(false);
const programs = ref<VotingProgram[]>([]);
const currentProgramId = ref<string | null>(null);
const allowLateCatch = ref(true);
// Currently selected stamp in dock
const selectedStamp = ref<TicketType | null>(null);
@@ -211,6 +228,63 @@ export const useVotingStore = defineStore('voting', () => {
tickets.value = { ...serverTickets };
}
// Sync voting state from AdminState
function syncVotingState(state: {
votingOpen?: boolean;
votingPaused?: boolean;
programs?: VotingProgram[];
currentProgramId?: string | null;
allowLateCatch?: boolean;
}) {
if (state.votingOpen !== undefined) votingOpen.value = state.votingOpen;
if (state.votingPaused !== undefined) votingPaused.value = state.votingPaused;
if (state.programs !== undefined) programs.value = state.programs;
if (state.currentProgramId !== undefined) currentProgramId.value = state.currentProgramId;
if (state.allowLateCatch !== undefined) allowLateCatch.value = state.allowLateCatch;
}
// Check if voting is allowed for a specific program
// In unified voting mode, all programs can be voted when voting is open
function canVoteForProgram(programId: string): { allowed: boolean; reason?: string } {
// Check if voting is open
if (!votingOpen.value) {
return { allowed: false, reason: '投票尚未开始' };
}
if (votingPaused.value) {
return { allowed: false, reason: '投票已暂停' };
}
// In unified voting mode, all programs are votable when voting is open
return { allowed: true };
}
// Handle real-time vote updates
function handleVoteUpdate(data: { candidateId: string; newCount: number; totalVotes: number }) {
const program = programs.value.find(p => p.id === data.candidateId);
if (program) {
program.votes = data.newCount;
}
// Update global total votes if provided
if (data.totalVotes !== undefined) {
// We don't have a totalVotes ref in the store yet, but we could add it or just ignore
}
}
// Get current voting program
const currentProgram = computed(() => {
if (!currentProgramId.value) return null;
return programs.value.find(p => p.id === currentProgramId.value) || null;
});
// Get votable programs (voting or completed with late catch)
const votablePrograms = computed(() => {
return programs.value.filter(p => {
if (p.status === 'voting') return true;
if (p.status === 'completed' && allowLateCatch.value) return true;
return false;
});
});
return {
tickets,
selectedStamp,
@@ -225,5 +299,17 @@ export const useVotingStore = defineStore('voting', () => {
castVote,
revokeVote,
syncFromServer,
// New exports
votingOpen,
votingPaused,
programs,
currentProgramId,
allowLateCatch,
currentProgram,
votablePrograms,
syncVotingState,
canVoteForProgram,
handleVoteUpdate,
};
});