diff --git a/packages/client-mobile/src/assets/images/stamp-horse-2026.png b/packages/client-mobile/src/assets/images/stamp-horse-2026.png new file mode 100644 index 0000000..60ff07f Binary files /dev/null and b/packages/client-mobile/src/assets/images/stamp-horse-2026.png differ diff --git a/packages/client-mobile/src/components/Postmark.vue b/packages/client-mobile/src/components/Postmark.vue index b4c68ec..73b5f98 100644 --- a/packages/client-mobile/src/components/Postmark.vue +++ b/packages/client-mobile/src/components/Postmark.vue @@ -44,83 +44,40 @@ const currentDate = computed(() => { opacity: inkOpacity, }" > - + - - + + + - - - - + - - + + - - + + + {{ awardName }} - - - - - {{ currentDate }}{{ userName ? ` · ${userName}` : '' }} + + + + {{ currentDate }} + + + + + + {{ userName || '访客' }} - - - - {{ awardIcon }} - - - - - @@ -133,9 +90,9 @@ const currentDate = computed(() => { .postmark { position: relative; - width: 80px; - height: 80px; - // CRITICAL: Multiply blend mode for ink absorption effect + width: 70px; + height: 70px; + // Multiply blend mode for realism mix-blend-mode: multiply; animation: stamp-reveal 0.3s ease-out forwards; transform-origin: center center; @@ -147,34 +104,24 @@ const currentDate = computed(() => { display: block; } -.center-icon { - // Emoji doesn't take fill color, but we can adjust opacity - opacity: 0.9; +.postmark-text { + font-family: 'Kaiti', 'STKaiti', serif; + font-weight: bold; + + &.top { font-size: 11px; } + &.date { font-size: 10px; letter-spacing: 0.5px; } + &.bottom { font-size: 10px; } } -// Grunge texture for realistic ink imperfection .grunge-overlay { position: absolute; inset: 0; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.12'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.08'/%3E%3C/svg%3E"); mix-blend-mode: overlay; pointer-events: none; border-radius: 50%; } -// Color variants -.postmark--red { - .postmark-svg { - filter: drop-shadow(0 0 1px rgba(194, 31, 48, 0.3)); - } -} - -.postmark--gold { - .postmark-svg { - filter: drop-shadow(0 0 1px rgba(212, 168, 75, 0.3)); - } -} - @keyframes stamp-reveal { 0% { opacity: 0; diff --git a/packages/client-mobile/src/components/ProgramCard.vue b/packages/client-mobile/src/components/ProgramCard.vue index 74b4f90..478cdc0 100644 --- a/packages/client-mobile/src/components/ProgramCard.vue +++ b/packages/client-mobile/src/components/ProgramCard.vue @@ -3,7 +3,9 @@ import { computed, ref } from 'vue'; import { useVotingStore, TICKET_INFO } from '../stores/voting'; import { useConnectionStore } from '../stores/connection'; import Postmark from './Postmark.vue'; +import stampImage from '../assets/images/stamp-horse-2026.png'; import type { TicketType } from '@gala/shared/constants'; +import type { VoteStamp } from '@gala/shared/types'; interface Props { programId: string; @@ -11,10 +13,14 @@ interface Props { teamName?: string; coverImage?: string; index?: number; // For stagger animation + status?: 'pending' | 'voting' | 'completed'; + isCurrent?: boolean; } const props = withDefaults(defineProps(), { index: 0, + status: 'pending', + isCurrent: false, }); const votingStore = useVotingStore(); @@ -33,9 +39,23 @@ const stampInfo = computed(() => { return TICKET_INFO[stampedWith.value]; }); -// Can this card receive a stamp +// Can this card receive a stamp (requires voting permission check) +const canVote = computed(() => { + const check = votingStore.canVoteForProgram(props.programId); + return check.allowed; +}); + const canReceiveStamp = computed(() => { - return votingStore.isStampSelected && !hasStamp.value; + return votingStore.isStampSelected && !hasStamp.value && canVote.value; +}); + +// Get voting status message +const votingStatusLabel = computed(() => { + switch (props.status) { + case 'voting': return '投票中'; + case 'completed': return '已结束'; + default: return '待投票'; + } }); // Stagger delay for entrance animation @@ -84,10 +104,16 @@ function delay(ms: number): Promise { 'has-stamp': hasStamp, 'can-stamp': canReceiveStamp, 'is-stamping': isStamping, + 'is-current': isCurrent, + 'is-disabled': !canVote && !hasStamp, }" :style="{ '--entrance-delay': entranceDelay }" @click="handleCardClick" > + +
+ {{ votingStatusLabel }} +
@@ -128,6 +154,10 @@ function delay(ms: number): Promise {
+ + 邮票 + + { color="red" class="applied-stamp" /> -
- 贴票处 - PLACE STAMP HERE -
@@ -177,7 +203,7 @@ $ink-charcoal: #333; border-radius: 4px; overflow: hidden; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.3s ease; // Entrance animation animation: postcard-enter 0.5s ease-out backwards; @@ -197,6 +223,43 @@ $ink-charcoal: #333; background-color: color.adjust($paper-cream, $lightness: -2%); } } + + &.is-current { + box-shadow: 0 4px 25px rgba($color-gold, 0.4), 0 0 0 3px $color-gold; + } + + &.is-disabled { + opacity: 0.6; + pointer-events: none; + } +} + +// Status Badge +.status-badge { + position: absolute; + top: 8px; + left: 8px; + padding: 4px 10px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + z-index: 5; + + &.voting { + background: $color-gold; + color: #000; + animation: pulse-badge 1.5s ease-in-out infinite; + } + + &.completed { + background: rgba(0, 0, 0, 0.5); + color: #fff; + } +} + +@keyframes pulse-badge { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } } .paper-texture { @@ -334,13 +397,24 @@ $ink-charcoal: #333; width: 70px; height: 70px; display: flex; - align-items: center; - justify-content: center; + align-items: flex-start; + justify-content: flex-end; +} + +.stamp-image { + width: 60px; + height: 60px; + object-fit: contain; + border: 1px solid rgba(0, 0, 0, 0.1); } .applied-stamp { + position: absolute; + bottom: -5px; + left: -10px; mix-blend-mode: multiply; - transform: rotate(5deg); + transform: rotate(-8deg); + z-index: 2; } .stamp-placeholder { diff --git a/packages/client-mobile/src/stores/connection.ts b/packages/client-mobile/src/stores/connection.ts index 673e58c..4912db9 100644 --- a/packages/client-mobile/src/stores/connection.ts +++ b/packages/client-mobile/src/stores/connection.ts @@ -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(null); const userName = ref(null); + const department = ref(null); const votedCategories = ref([]); // 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 diff --git a/packages/client-mobile/src/stores/voting.ts b/packages/client-mobile/src/stores/voting.ts index 9bf2751..3e455a0 100644 --- a/packages/client-mobile/src/stores/voting.ts +++ b/packages/client-mobile/src/stores/voting.ts @@ -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 = { @@ -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([]); + const currentProgramId = ref(null); + const allowLateCatch = ref(true); + // Currently selected stamp in dock const selectedStamp = ref(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, }; }); + diff --git a/packages/client-mobile/src/views/HomeView.vue b/packages/client-mobile/src/views/HomeView.vue index b43c5f9..1734f6b 100644 --- a/packages/client-mobile/src/views/HomeView.vue +++ b/packages/client-mobile/src/views/HomeView.vue @@ -8,6 +8,7 @@ const router = useRouter(); const connectionStore = useConnectionStore(); const userName = ref(''); +const userDept = ref('技术部'); const isLoading = ref(false); async function handleEnter() { @@ -21,7 +22,7 @@ async function handleEnter() { // Generate a simple user ID (in production, this would come from auth) const odrawId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - connectionStore.setUser(odrawId, userName.value.trim()); + connectionStore.setUser(odrawId, userName.value.trim(), userDept.value); // Wait for connection await new Promise((resolve) => setTimeout(resolve, 500)); @@ -61,6 +62,17 @@ async function handleEnter() { @keyup.enter="handleEnter" /> + +
+ +
-import { ref, onMounted } from 'vue'; +import { computed, onMounted } from 'vue'; import { useVotingStore } from '../stores/voting'; import { useConnectionStore } from '../stores/connection'; import VotingDock from '../components/VotingDock.vue'; @@ -8,19 +8,29 @@ import ProgramCard from '../components/ProgramCard.vue'; const votingStore = useVotingStore(); const connectionStore = useConnectionStore(); -// 节目列表(从后端获取,这里先用 mock 数据) -const programs = ref([ - { id: 'p1', name: '龙腾四海', team: '市场部', coverImage: '' }, - { id: 'p2', name: '金马奔腾', team: '技术部', coverImage: '' }, - { id: 'p3', name: '春风得意', team: '人力资源部', coverImage: '' }, - { id: 'p4', name: '鸿运当头', team: '财务部', coverImage: '' }, - { id: 'p5', name: '马到成功', team: '运营部', coverImage: '' }, - { id: 'p6', name: '一马当先', team: '产品部', coverImage: '' }, - { id: 'p7', name: '万马奔腾', team: '设计部', coverImage: '' }, - { id: 'p8', name: '龙马精神', team: '销售部', coverImage: '' }, -]); +// Use server-synced programs from voting store +const programs = computed(() => { + // If no programs from server, show default list + if (votingStore.programs.length === 0) { + return [ + { id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending' as const, votes: 0 }, + { id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending' as const, votes: 0 }, + { id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending' as const, votes: 0 }, + { id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending' as const, votes: 0 }, + { id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending' as const, votes: 0 }, + { id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending' as const, votes: 0 }, + { id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending' as const, votes: 0 }, + { id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending' as const, votes: 0 }, + ]; + } + return votingStore.programs; +}); -const isLoading = ref(false); +// Voting status message +const votingStatusMessage = computed(() => { + if (!votingStore.votingOpen) return '投票尚未开始'; + return '投票进行中'; +}); onMounted(() => { if (!connectionStore.isConnected) { @@ -44,15 +54,23 @@ onMounted(() => { + +
+ + {{ votingStatusMessage }} +
+
@@ -132,4 +150,51 @@ onMounted(() => { flex-direction: column; gap: $spacing-lg; } + +// Voting Status Bar +.voting-status-bar { + display: flex; + align-items: center; + gap: 8px; + padding: $spacing-sm $spacing-lg; + background: rgba(255, 255, 255, 0.05); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + font-size: $font-size-sm; + color: $color-text-secondary; + + &.active { + background: rgba($color-gold, 0.1); + border-color: rgba($color-gold, 0.2); + } +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #666; + + &.pulsing { + background: #22c55e; + animation: pulse-dot 1.5s ease-in-out infinite; + } +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); } + 50% { opacity: 0.8; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); } +} + +.status-text { + flex: 1; +} + +.late-catch-hint { + font-size: 10px; + color: $color-gold; + padding: 2px 8px; + background: rgba($color-gold, 0.15); + border-radius: 4px; +} + diff --git a/packages/client-screen/src/assets/images/stamp-horse-2026.png b/packages/client-screen/src/assets/images/stamp-horse-2026.png new file mode 100644 index 0000000..60ff07f Binary files /dev/null and b/packages/client-screen/src/assets/images/stamp-horse-2026.png differ diff --git a/packages/client-screen/src/components/PostcardDisplay.vue b/packages/client-screen/src/components/PostcardDisplay.vue new file mode 100644 index 0000000..e4da6d9 --- /dev/null +++ b/packages/client-screen/src/components/PostcardDisplay.vue @@ -0,0 +1,466 @@ + + + + + + diff --git a/packages/client-screen/src/components/PostcardGrid.vue b/packages/client-screen/src/components/PostcardGrid.vue new file mode 100644 index 0000000..0c0fecf --- /dev/null +++ b/packages/client-screen/src/components/PostcardGrid.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/packages/client-screen/src/components/PostcardItem.vue b/packages/client-screen/src/components/PostcardItem.vue new file mode 100644 index 0000000..55d8ab5 --- /dev/null +++ b/packages/client-screen/src/components/PostcardItem.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/packages/client-screen/src/components/Postmark.vue b/packages/client-screen/src/components/Postmark.vue new file mode 100644 index 0000000..73b5f98 --- /dev/null +++ b/packages/client-screen/src/components/Postmark.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/packages/client-screen/src/stores/admin.ts b/packages/client-screen/src/stores/admin.ts index 8fd46f2..7980c41 100644 --- a/packages/client-screen/src/stores/admin.ts +++ b/packages/client-screen/src/stores/admin.ts @@ -33,6 +33,9 @@ export const useAdminStore = defineStore('admin', () => { const votingOpen = ref(false); const votingPaused = ref(false); const totalVotes = ref(0); + const programs = ref>([]); + const allowLateCatch = ref(true); + const currentProgramId = ref(null); // Lottery State const lotteryRound = ref(1); @@ -143,7 +146,7 @@ export const useAdminStore = defineStore('admin', () => { userId: 'admin_main', userName: 'Admin Console', role: 'admin', - }, () => {}); + }, () => { }); // Request state sync socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, {}); @@ -177,6 +180,9 @@ export const useAdminStore = defineStore('admin', () => { votingOpen.value = state.voting.subPhase === 'OPEN'; votingPaused.value = state.voting.subPhase === 'PAUSED'; totalVotes.value = state.voting.totalVotes; + programs.value = state.voting.programs || []; + allowLateCatch.value = state.voting.allowLateCatch ?? true; + currentProgramId.value = state.voting.currentProgramId || null; lotteryRound.value = state.lottery.round; lotterySubPhase.value = state.lottery.subPhase; currentWinners.value = state.lottery.currentWinners; @@ -361,6 +367,9 @@ export const useAdminStore = defineStore('admin', () => { votingOpen, votingPaused, totalVotes, + programs, + allowLateCatch, + currentProgramId, lotteryRound, lotterySubPhase, stormStartedAt, @@ -387,5 +396,6 @@ export const useAdminStore = defineStore('admin', () => { emergencyReset, controlMusic, clearError, + getSocket: () => socket.value, }; }); diff --git a/packages/client-screen/src/views/AdminControl.vue b/packages/client-screen/src/views/AdminControl.vue index ee884d2..50b23c8 100644 --- a/packages/client-screen/src/views/AdminControl.vue +++ b/packages/client-screen/src/views/AdminControl.vue @@ -43,6 +43,41 @@ function resumeVoting() { admin.controlVoting('resume'); } +// Program voting control +function nextProgram() { + const socket = admin.getSocket(); + if (socket) { + socket.emit('admin:next_program' as any, {}, (response: any) => { + if (!response.success) { + console.error('Failed to move to next program:', response.message); + } + }); + } +} + +function startProgramVoting(programId: string) { + const socket = admin.getSocket(); + if (socket) { + socket.emit('admin:start_program' as any, { programId }, (response: any) => { + if (!response.success) { + console.error('Failed to start program voting:', response.message); + } + }); + } +} + +function toggleLateCatch() { + const socket = admin.getSocket(); + if (socket) { + const newValue = !admin.allowLateCatch; + socket.emit('admin:toggle_late_catch' as any, { enabled: newValue }, (response: any) => { + if (!response.success) { + console.error('Failed to toggle late catch:', response.message); + } + }); + } +} + // Lottery control function startGalaxy() { admin.controlLottery('start_galaxy'); @@ -224,6 +259,30 @@ onUnmounted(() => { + +
+
+

节目票数

+ 共 {{ admin.programs.length }} 个节目 +
+ +
+ +
+
+ {{ idx + 1 }} + {{ program.name }} + {{ program.teamName }} + {{ program.votes }} 票 +
+
+
+
+
diff --git a/packages/client-screen/src/views/LiveVotingView.vue b/packages/client-screen/src/views/LiveVotingView.vue index e9bdbed..6f35463 100644 --- a/packages/client-screen/src/views/LiveVotingView.vue +++ b/packages/client-screen/src/views/LiveVotingView.vue @@ -1,214 +1,191 @@ + diff --git a/packages/server/src/services/admin.service.ts b/packages/server/src/services/admin.service.ts index 8838ab2..0494621 100644 --- a/packages/server/src/services/admin.service.ts +++ b/packages/server/src/services/admin.service.ts @@ -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:*'); diff --git a/packages/server/src/socket/index.ts b/packages/server/src/socket/index.ts index 4f9eeb9..c398020 100644 --- a/packages/server/src/socket/index.ts +++ b/packages/server/src/socket/index.ts @@ -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) => { + handleStartProgram(socket, data, callback); + }); + + socket.on('admin:next_program' as any, (data: any, callback: AckCallback) => { + handleNextProgram(socket, callback); + }); + + socket.on('admin:toggle_late_catch' as any, (data: { enabled: boolean }, callback: AckCallback) => { + handleToggleLateCatch(socket, data, callback); + }); + // Disconnect handler socket.on('disconnect', (reason) => { handleDisconnect(socket, reason); @@ -135,11 +148,12 @@ async function handleJoin( callback: AckCallback ): Promise { 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 }); + } +} + diff --git a/packages/shared/src/types/admin.types.ts b/packages/shared/src/types/admin.types.ts index b930529..e587eba 100644 --- a/packages/shared/src/types/admin.types.ts +++ b/packages/shared/src/types/admin.types.ts @@ -34,6 +34,36 @@ export interface VotingState { openedAt?: number; closedAt?: number; totalVotes: number; + // 节目投票控制 + currentProgramId: string | null; // 当前投票节目 ID + currentProgramIndex: number; // 当前节目序号 (0-based index) + programs: VotingProgram[]; // 节目列表(已排序) + allowLateCatch: boolean; // 补投票开关(默认 true) + votingStartedAt?: number; // 当前节目投票开始时间(用于计时) +} + +export type ProgramVotingStatus = 'pending' | 'voting' | 'completed'; + +export interface VotingProgram { + id: string; + name: string; + teamName: string; + order: number; // 初始顺序 + status: ProgramVotingStatus; // 投票状态 + votes: number; // 票数 + stamps: VoteStamp[]; // 已盖的戳(用于大屏展示) +} + +export interface VoteStamp { + id: string; + userName: string; + department: string; + ticketType: string; + x: number; // 随机 X 位置 (0-100%) + y: number; // 随机 Y 位置 (0-100%) + rotation: number; // 随机旋转角度 + timestamp: number; + date: string; // 格式化日期 YYYY.MM.DD } export interface LotteryState { @@ -116,6 +146,18 @@ export const PRIZE_CONFIG: PrizeConfig[] = [ { round: 4, level: '三等奖', name: '京东卡 500元', winnerCount: 10, zodiacFilter: 'horse' }, ]; +// Default programs for voting +export const DEFAULT_PROGRAMS: VotingProgram[] = [ + { id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending', votes: 0, stamps: [] }, + { id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending', votes: 0, stamps: [] }, + { id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending', votes: 0, stamps: [] }, + { id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending', votes: 0, stamps: [] }, + { id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending', votes: 0, stamps: [] }, + { id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending', votes: 0, stamps: [] }, + { id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending', votes: 0, stamps: [] }, + { id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending', votes: 0, stamps: [] }, +]; + // ============================================================================ // Initial State // ============================================================================ @@ -125,6 +167,10 @@ export const INITIAL_ADMIN_STATE: AdminState = { voting: { subPhase: 'CLOSED', totalVotes: 0, + currentProgramId: null, + currentProgramIndex: -1, + programs: DEFAULT_PROGRAMS, + allowLateCatch: true, }, lottery: { round: 1, diff --git a/packages/shared/src/types/socket.types.ts b/packages/shared/src/types/socket.types.ts index 079d1aa..93b5baf 100644 --- a/packages/shared/src/types/socket.types.ts +++ b/packages/shared/src/types/socket.types.ts @@ -82,6 +82,7 @@ export interface DrawFilters { export interface JoinPayload { userId: string; userName: string; + department?: string; role: UserRole; sessionToken?: string; } @@ -189,6 +190,7 @@ export interface InterServerEvents { export interface SocketData { userId: string; userName: string; + department: string; role: UserRole; connectedAt: Date; sessionId: string;