import { defineStore } from 'pinia'; import { ref, computed, shallowRef } from 'vue'; import { io, type Socket } from 'socket.io-client'; import type { ServerToClientEvents, ClientToServerEvents, ConnectionAckPayload, VoteCategory, SyncStatePayload, AdminState, } from '@gala/shared/types'; import { SOCKET_EVENTS } from '@gala/shared/constants'; import { CONFIG } from '@gala/shared/constants'; type GalaSocket = Socket; // LocalStorage keys const STORAGE_KEYS = { USER_ID: 'gala_user_id', USER_NAME: 'gala_user_name', DEPARTMENT: 'gala_department', }; // Helper functions for localStorage function loadFromStorage(key: string, defaultValue: T): T { try { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : defaultValue; } catch { return defaultValue; } } function saveToStorage(key: string, value: unknown): void { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn('[Storage] Failed to save:', e); } } export const useConnectionStore = defineStore('connection', () => { // State - use shallowRef for socket to avoid deep reactivity issues const socket = shallowRef(null); const isConnected = ref(false); const isConnecting = ref(false); const sessionId = ref(null); const lastPingTime = ref(0); const latency = ref(0); const reconnectAttempts = ref(0); const userId = ref(loadFromStorage(STORAGE_KEYS.USER_ID, null)); const userName = ref(loadFromStorage(STORAGE_KEYS.USER_NAME, null)); const department = ref(loadFromStorage(STORAGE_KEYS.DEPARTMENT, null)); const votedCategories = ref([]); // Computed const connectionStatus = computed(() => { if (isConnected.value) return 'connected'; if (isConnecting.value) return 'connecting'; return 'disconnected'; }); const remainingVotes = computed(() => { return CONFIG.MAX_VOTES_PER_USER - votedCategories.value.length; }); // Heartbeat interval let heartbeatInterval: ReturnType | null = null; let pongTimeout: ReturnType | null = null; /** * 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: CONFIG.RECONNECTION_DELAY_MS, reconnectionDelayMax: CONFIG.RECONNECTION_DELAY_MAX_MS, timeout: CONFIG.HEARTBEAT_TIMEOUT_MS, transports: ['websocket', 'polling'], }); // Connection events socketInstance.on('connect', () => { console.log('[Socket] Connected'); isConnected.value = true; isConnecting.value = false; reconnectAttempts.value = 0; // Auto-generate userId if not set (and save to localStorage) if (!userId.value) { userId.value = `user_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; userName.value = '访客'; saveToStorage(STORAGE_KEYS.USER_ID, userId.value); saveToStorage(STORAGE_KEYS.USER_NAME, userName.value); } // Join with user info joinRoom(); // Start heartbeat startHeartbeat(); // Request initial admin state to ensure UI is in sync socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any); }); socketInstance.on('disconnect', (reason) => { console.log('[Socket] Disconnected:', reason); isConnected.value = false; stopHeartbeat(); }); socketInstance.on('connect_error', (error) => { console.error('[Socket] Connection error:', error); isConnecting.value = false; reconnectAttempts.value++; }); // Custom pong handler socketInstance.on('connection:pong' as any, () => { if (pongTimeout) { clearTimeout(pongTimeout); pongTimeout = null; } latency.value = Date.now() - lastPingTime.value; }); // Connection acknowledgment socketInstance.on('connection:ack' as any, (data: ConnectionAckPayload) => { sessionId.value = data.sessionId; if (data.reconnected && data.missedEvents) { // Handle missed events console.log('[Socket] Reconnected, processing missed events'); } }); // Sync state socketInstance.on('sync:state' as any, (data: SyncStatePayload) => { if (data.userVotedCategories) { votedCategories.value = data.userVotedCategories; } if (data.userTickets) { import('./voting').then(({ useVotingStore }) => { const votingStore = useVotingStore(); votingStore.syncFromServer(data.userTickets as Record); }); } }); // 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, awards: state.voting.awards, }); }); }); // 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; } /** * Join room with user info */ function joinRoom() { if (!socket.value || !userId.value) return; socket.value.emit( SOCKET_EVENTS.CONNECTION_JOIN as any, { userId: userId.value, userName: userName.value || 'Guest', department: department.value || '未知部门', role: 'user', }, (response: any) => { if (response.success) { sessionId.value = response.data.sessionId; if (response.data.votedCategories) { votedCategories.value = response.data.votedCategories; } if (response.data.userTickets) { import('./voting').then(({ useVotingStore }) => { const votingStore = useVotingStore(); votingStore.syncFromServer(response.data.userTickets); }); } // 连接成功后主动同步票据,避免刷新后丢失投票状态 requestSync(); } } ); } /** * Start heartbeat */ function startHeartbeat() { stopHeartbeat(); heartbeatInterval = setInterval(() => { if (socket.value?.connected) { lastPingTime.value = Date.now(); socket.value.emit(SOCKET_EVENTS.CONNECTION_PING as any); // Set pong timeout pongTimeout = setTimeout(() => { console.warn('[Socket] Pong timeout, connection may be unstable'); }, CONFIG.HEARTBEAT_TIMEOUT_MS); } }, CONFIG.HEARTBEAT_INTERVAL_MS); } /** * Stop heartbeat */ function stopHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (pongTimeout) { clearTimeout(pongTimeout); pongTimeout = null; } } /** * Disconnect from server */ function disconnect() { stopHeartbeat(); if (socket.value) { socket.value.disconnect(); socket.value = null; } isConnected.value = false; isConnecting.value = false; } /** * Set user info (and persist to localStorage) */ function setUser(id: string, name: string, dept: string) { userId.value = id; userName.value = name; department.value = dept; // Persist to localStorage saveToStorage(STORAGE_KEYS.USER_ID, id); saveToStorage(STORAGE_KEYS.USER_NAME, name); saveToStorage(STORAGE_KEYS.DEPARTMENT, dept); // Rejoin if already connected if (socket.value?.connected) { joinRoom(); } } /** * Logout and clear stored user info */ function logout() { // Clear state userId.value = null; userName.value = null; department.value = null; votedCategories.value = []; // Clear localStorage localStorage.removeItem(STORAGE_KEYS.USER_ID); localStorage.removeItem(STORAGE_KEYS.USER_NAME); localStorage.removeItem(STORAGE_KEYS.DEPARTMENT); // Disconnect socket disconnect(); } /** * Add voted category */ function addVotedCategory(category: VoteCategory) { if (!votedCategories.value.includes(category)) { votedCategories.value.push(category); } } /** * Request sync from server */ function requestSync() { if (socket.value?.connected) { socket.value.emit(SOCKET_EVENTS.SYNC_REQUEST as any, {}); } } /** * Get socket instance (for advanced usage) */ function getSocket(): GalaSocket | null { return socket.value; } return { // State (excluding socket to avoid type inference issues) isConnected, isConnecting, sessionId, latency, reconnectAttempts, userId, userName, department, votedCategories, // Computed connectionStatus, remainingVotes, // Actions connect, disconnect, logout, setUser, addVotedCategory, requestSync, getSocket, }; });