Files
company-celebration/packages/client-mobile/src/stores/connection.ts
let5sne 296f6e09f8 fix: add MySQL database and fix deployment issues
## 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>
2026-02-02 21:46:51 +08:00

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,
};
});