Files
company-celebration/packages/client-screen/src/stores/display.ts
empty 48d61a1e15 feat: 新增签到墙、摇一摇等功能及开发环境配置
新功能:
- 签到墙页面 (CheckinWallView) 及后端接口
- 摇一摇互动页面 (ShakeView) 及服务
- 头像服务 (avatar.service)
- 微信公众号静默授权登录增强

开发环境:
- 新增 dev-tunnel skill 用于本地调试
- docker-compose.dev.yml 开发环境配置
- 客户端 .env.development 配置文件

其他改进:
- VoteView 投票页面功能增强
- AdminControl 管理控制台更新
- 连接状态管理优化
- 新增马蹄声音效

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:25:56 +08:00

278 lines
7.5 KiB
TypeScript

import { defineStore } from 'pinia';
import { ref, computed, shallowRef, watch } from 'vue';
import { io, Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
DrawStartPayload,
DrawWinnerPayload,
VoteUpdatePayload,
AdminState,
SystemPhase,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
// Audio player singleton
let audioPlayer: HTMLAudioElement | null = null;
// Audio file paths - place audio files in public/audio/
const AUDIO_TRACKS: Record<string, string> = {
bgm: '/screen/audio/bgm.mp3',
lottery: '/screen/audio/lottery.mp3',
fanfare: '/screen/audio/fanfare.mp3',
award: '/screen/audio/award.mp3',
horse: '/screen/audio/horse.mp3', // 第四轮抽奖"属马"专属音效
};
export const useDisplayStore = defineStore('display', () => {
// State - use shallowRef for socket to avoid deep reactivity issues
const socket = shallowRef<GalaSocket | null>(null);
const isConnected = ref(false);
const isConnecting = ref(false);
const onlineUsers = ref(0);
const currentMode = ref<'idle' | 'voting' | 'draw' | 'results' | 'lottery_results' | 'checkin_wall'>('idle');
const eventTitle = ref('公司2026年会');
// Draw state
const isDrawing = ref(false);
const currentPrize = ref<string | null>(null);
const currentWinner = ref<{
userId: string;
userName: string;
department: string;
avatar?: string;
} | null>(null);
// QR Code display state (controlled by admin)
const showEntryQR = ref(false);
// Music state
const musicPlaying = ref(false);
const musicTrack = ref<string>('none');
// Computed
const connectionStatus = computed(() => {
if (isConnected.value) return 'connected';
if (isConnecting.value) return 'connecting';
return 'disconnected';
});
/**
* Connect to WebSocket server
*/
function connect() {
if (socket.value?.connected || isConnecting.value) {
return;
}
isConnecting.value = true;
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 10000,
transports: ['websocket', 'polling'],
});
// Connection events
socketInstance.on('connect', () => {
console.log('[Screen] Connected');
isConnected.value = true;
isConnecting.value = false;
// Join as screen
socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, {
userId: 'screen_main',
userName: 'Main Display',
role: 'screen',
}, () => { });
});
socketInstance.on('disconnect', (reason) => {
console.log('[Screen] Disconnected:', reason);
isConnected.value = false;
});
socketInstance.on('connect_error', (error) => {
console.error('[Screen] Connection error:', error);
isConnecting.value = false;
});
// User count updates
socketInstance.on('connection:users_count' as any, (count: number) => {
onlineUsers.value = count;
});
// Draw events
socketInstance.on('draw:started' as any, (data: DrawStartPayload) => {
isDrawing.value = true;
currentPrize.value = data.prizeName;
currentWinner.value = null;
currentMode.value = 'draw';
});
socketInstance.on('draw:winner' as any, (data: DrawWinnerPayload) => {
currentWinner.value = data.winner;
});
socketInstance.on('draw:ended' as any, () => {
isDrawing.value = false;
});
// Vote updates
socketInstance.on('vote:updated' as any, (data: VoteUpdatePayload) => {
// Emit custom event for components to handle
window.dispatchEvent(new CustomEvent('vote:updated', { detail: data }));
});
// Admin state sync - listen for phase changes from director console
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
console.log('[Screen] Admin state sync received:', state.systemPhase);
// Update event title if changed
if (state.eventTitle && state.eventTitle !== eventTitle.value) {
eventTitle.value = state.eventTitle;
}
// Map SystemPhase to display mode
const phaseToMode: Record<SystemPhase, 'idle' | 'voting' | 'draw' | 'results' | 'lottery_results' | 'checkin_wall'> = {
'IDLE': 'idle',
'VOTING': 'voting',
'LOTTERY': 'draw',
'RESULTS': 'results',
'LOTTERY_RESULTS': 'lottery_results',
'CHECKIN_WALL': 'checkin_wall',
};
const newMode = phaseToMode[state.systemPhase] || 'idle';
if (currentMode.value !== newMode) {
currentMode.value = newMode;
// Emit custom event for App.vue to handle route navigation
window.dispatchEvent(new CustomEvent('screen:mode_change', {
detail: { mode: newMode, phase: state.systemPhase }
}));
}
// Handle music state changes
if (state.music) {
const { isPlaying, track } = state.music;
if (isPlaying && track && track !== 'none') {
// Only play if not already playing the same track
if (!musicPlaying.value || musicTrack.value !== track) {
const shouldLoop = track === 'bgm' || track === 'lottery';
playAudio(track, shouldLoop);
}
} else if (!isPlaying && musicPlaying.value) {
stopAudio();
}
}
});
// QR Code display control events
socketInstance.on('display:show_entry_qr' as any, () => {
console.log('[Screen] Show entry QR code');
showEntryQR.value = true;
});
socketInstance.on('display:hide_qr' as any, () => {
console.log('[Screen] Hide QR code');
showEntryQR.value = false;
});
socket.value = socketInstance as GalaSocket;
}
/**
* Disconnect from server
*/
function disconnect() {
if (socket.value) {
socket.value.disconnect();
socket.value = null;
}
isConnected.value = false;
isConnecting.value = false;
}
/**
* Set display mode
*/
function setMode(mode: 'idle' | 'voting' | 'draw' | 'results') {
currentMode.value = mode;
}
/**
* Get socket instance (for advanced usage)
*/
function getSocket(): GalaSocket | null {
return socket.value;
}
/**
* Play audio track
*/
function playAudio(track: string, loop: boolean = false) {
stopAudio();
const src = AUDIO_TRACKS[track];
if (!src) {
console.warn(`[Screen] Unknown audio track: ${track}`);
return;
}
audioPlayer = new Audio(src);
audioPlayer.loop = loop;
audioPlayer.volume = 0.7;
audioPlayer.play().catch((err) => {
console.error('[Screen] Failed to play audio:', err);
});
musicPlaying.value = true;
musicTrack.value = track;
console.log(`[Screen] Playing audio: ${track}`);
}
/**
* Stop audio playback
*/
function stopAudio() {
if (audioPlayer) {
audioPlayer.pause();
audioPlayer.currentTime = 0;
audioPlayer = null;
}
musicPlaying.value = false;
musicTrack.value = 'none';
console.log('[Screen] Audio stopped');
}
return {
// State (excluding socket to avoid type inference issues)
isConnected,
isConnecting,
onlineUsers,
currentMode,
eventTitle,
isDrawing,
currentPrize,
currentWinner,
showEntryQR,
musicPlaying,
musicTrack,
// Computed
connectionStatus,
// Actions
connect,
disconnect,
setMode,
getSocket,
playAudio,
stopAudio,
};
});