## Changes ### Database Integration - Add MySQL 8.0 service to docker-compose.yml - Configure DATABASE_URL environment variable for server - Add health check for MySQL service - Create mysql_data volume for data persistence ### Dockerfile Improvements - Generate Prisma Client in builder stage - Copy Prisma Client from correct pnpm workspace location - Ensure Prisma Client is available in production container ### Client-Mobile Fixes - Remove deprecated StampDock.vue component - Fix voting store API usage in VotingPage.vue - Add type assertion for userTickets in connection.ts - Add remark property to AwardConfig interface in voting.ts ## Testing - All containers start successfully - Database connection established - Redis connection working - 94 participants restored from Redis - Vote data synced (20 votes) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
353 lines
9.6 KiB
TypeScript
353 lines
9.6 KiB
TypeScript
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<ServerToClientEvents, ClientToServerEvents>;
|
|
|
|
// LocalStorage keys
|
|
const STORAGE_KEYS = {
|
|
USER_ID: 'gala_user_id',
|
|
USER_NAME: 'gala_user_name',
|
|
DEPARTMENT: 'gala_department',
|
|
};
|
|
|
|
// Helper functions for localStorage
|
|
function loadFromStorage<T>(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<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>(loadFromStorage(STORAGE_KEYS.USER_ID, null));
|
|
const userName = ref<string | null>(loadFromStorage(STORAGE_KEYS.USER_NAME, null));
|
|
const department = ref<string | null>(loadFromStorage(STORAGE_KEYS.DEPARTMENT, 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;
|
|
|
|
// 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<string, string | null>);
|
|
});
|
|
}
|
|
});
|
|
// 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,
|
|
};
|
|
});
|