feat: initialize Annual Gala Interactive System monorepo

- Set up pnpm workspace with 4 packages: shared, server, client-mobile, client-screen
- Implement Redis atomic voting with Lua scripts (HINCRBY + distributed lock)
- Add optimistic UI with IndexedDB queue for offline resilience
- Configure Socket.io with auto-reconnection (infinite retries)
- Separate mobile (Vant) and big screen (Pixi.js) dependencies

Tech stack:
- Frontend Mobile: Vue 3 + Vant + Socket.io-client
- Frontend Screen: Vue 3 + Pixi.js + GSAP
- Backend: Express + Socket.io + Redis + Prisma/MySQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-15 01:19:36 +08:00
commit e7397d22a9
74 changed files with 14088 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
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,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
import { CONFIG } from '@gala/shared/constants';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
export const useConnectionStore = defineStore('connection', () => {
// 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 sessionId = ref<string | null>(null);
const lastPingTime = ref<number>(0);
const latency = ref<number>(0);
const reconnectAttempts = ref(0);
const userId = ref<string | null>(null);
const userName = ref<string | null>(null);
const votedCategories = ref<VoteCategory[]>([]);
// 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<typeof setInterval> | null = null;
let pongTimeout: ReturnType<typeof setTimeout> | 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;
// Join with user info
joinRoom();
// Start heartbeat
startHeartbeat();
});
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;
}
});
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',
role: 'user',
},
(response: any) => {
if (response.success) {
sessionId.value = response.data.sessionId;
if (response.data.votedCategories) {
votedCategories.value = response.data.votedCategories;
}
}
}
);
}
/**
* 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
*/
function setUser(id: string, name: string) {
userId.value = id;
userName.value = name;
// Rejoin if already connected
if (socket.value?.connected) {
joinRoom();
}
}
/**
* 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,
votedCategories,
// Computed
connectionStatus,
remainingVotes,
// Actions
connect,
disconnect,
setUser,
addVotedCategory,
requestSync,
getSocket,
};
});