新功能: - 签到墙页面 (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>
278 lines
7.5 KiB
TypeScript
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,
|
|
};
|
|
});
|