Files
company-celebration/packages/client-screen/src/stores/admin.ts

459 lines
14 KiB
TypeScript

import { defineStore } from 'pinia';
import { ref, computed, shallowRef, watch } from 'vue';
import { io, Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
AdminState,
SystemPhase,
VotingControlPayload,
LotteryControlPayload,
EmergencyResetPayload,
MusicControlPayload,
LotteryRound,
} from '@gala/shared/types';
import { PRIZE_CONFIG } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
import { ADMIN_TOKEN_KEY } from '../router';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
// Storage key for state persistence
const STORAGE_KEY = 'gala_admin_state';
export const useAdminStore = defineStore('admin', () => {
// Socket
const socket = shallowRef<GalaSocket | null>(null);
const isConnected = ref(false);
const isConnecting = ref(false);
const latency = ref(0);
const onlineUsers = ref(0);
// Admin State (mirrors server state)
const systemPhase = ref<SystemPhase>('IDLE');
const votingOpen = ref(false);
const votingPaused = ref(false);
const totalVotes = ref(0);
const programs = ref<Array<{ id: string; name: string; teamName: string; performer?: string; remark?: string; order: number; status: string; votes: number; stamps: any[] }>>([]);
const allowLateCatch = ref(true);
const currentProgramId = ref<string | null>(null);
const awards = ref<any[]>([]);
// Lottery State
const lotteryRound = ref<LotteryRound>(1);
const lotterySubPhase = ref<'IDLE' | 'GALAXY' | 'STORM' | 'REVEAL' | 'COMPLETE'>('IDLE');
const stormStartedAt = ref<number | null>(null);
const currentWinners = ref<Array<{ id: string; name: string; department: string }>>([]);
// Timer for countdown updates
const currentTime = ref(Date.now());
let countdownTimer: ReturnType<typeof setInterval> | null = null;
function startCountdownTimer() {
if (countdownTimer) return;
countdownTimer = setInterval(() => {
currentTime.value = Date.now();
}, 100);
}
function stopCountdownTimer() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
// Music State
const musicPlaying = ref(false);
const musicTrack = ref<'bgm' | 'lottery' | 'fanfare' | 'none'>('none');
// UI State
const pendingAction = ref<string | null>(null);
const lastError = ref<string | null>(null);
const lastActionTime = ref(0);
// Computed
const connectionStatus = computed(() => {
if (isConnected.value) return 'connected';
if (isConnecting.value) return 'connecting';
return 'disconnected';
});
const currentPrize = computed(() => {
const config = PRIZE_CONFIG.find(p => p.round === lotteryRound.value);
return config || PRIZE_CONFIG[0];
});
const canStopLottery = computed(() => {
if (lotterySubPhase.value !== 'STORM') return false;
if (!stormStartedAt.value) return false;
// 3-second safety delay (use currentTime for reactivity)
return currentTime.value - stormStartedAt.value >= 3000;
});
const stopButtonCountdown = computed(() => {
if (lotterySubPhase.value !== 'STORM') return 0;
if (!stormStartedAt.value) return 3;
const elapsed = currentTime.value - stormStartedAt.value;
return Math.max(0, Math.ceil((3000 - elapsed) / 1000));
});
// Persist state to localStorage
function saveState() {
const state = {
systemPhase: systemPhase.value,
votingOpen: votingOpen.value,
votingPaused: votingPaused.value,
lotteryRound: lotteryRound.value,
lotterySubPhase: lotterySubPhase.value,
stormStartedAt: stormStartedAt.value,
currentWinners: currentWinners.value,
musicPlaying: musicPlaying.value,
musicTrack: musicTrack.value,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
// Restore state from localStorage
function restoreState() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const state = JSON.parse(saved);
systemPhase.value = state.systemPhase || 'IDLE';
votingOpen.value = state.votingOpen || false;
votingPaused.value = state.votingPaused || false;
lotteryRound.value = state.lotteryRound || 1;
lotterySubPhase.value = state.lotterySubPhase || 'IDLE';
stormStartedAt.value = state.stormStartedAt || null;
currentWinners.value = state.currentWinners || [];
musicPlaying.value = state.musicPlaying || false;
musicTrack.value = state.musicTrack || 'none';
// If in STORM phase but stormStartedAt is missing, set it to allow stopping
if (lotterySubPhase.value === 'STORM' && !stormStartedAt.value) {
stormStartedAt.value = Date.now() - 4000; // Allow immediate stop
}
}
} catch (e) {
console.error('[Admin] Failed to restore state:', e);
}
}
// Watch for state changes and persist
watch(
[systemPhase, votingOpen, votingPaused, lotteryRound, lotterySubPhase, stormStartedAt, currentWinners, musicPlaying, musicTrack],
() => saveState(),
{ deep: true }
);
// Watch lotterySubPhase to manage countdown timer
watch(lotterySubPhase, (newPhase) => {
if (newPhase === 'STORM') {
startCountdownTimer();
} else {
stopCountdownTimer();
}
}, { immediate: true });
// Connect to server
function connect() {
if (socket.value?.connected || isConnecting.value) return;
isConnecting.value = true;
restoreState();
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 10000,
transports: ['websocket', 'polling'],
auth: adminToken ? { token: adminToken } : undefined,
});
socketInstance.on('connect', () => {
console.log('[Admin] Connected');
isConnected.value = true;
isConnecting.value = false;
// Join as admin
socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, {
userId: 'admin_main',
userName: 'Admin Console',
role: 'admin',
sessionToken: adminToken || undefined,
}, () => { });
// Request state sync
socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, {});
});
socketInstance.on('disconnect', (reason) => {
console.log('[Admin] Disconnected:', reason);
isConnected.value = false;
});
socketInstance.on('connect_error', (error) => {
console.error('[Admin] Connection error:', error);
isConnecting.value = false;
});
// User count updates
socketInstance.on('connection:users_count' as any, (count: number) => {
onlineUsers.value = count;
});
// Admin state sync from server
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
syncFromServer(state);
});
// Real-time vote updates (for totalVotes and program votes)
socketInstance.on(SOCKET_EVENTS.VOTE_UPDATED as any, (data: { candidateId: string; totalVotes?: number; programVotes?: number; delta?: number }) => {
const program = programs.value.find(p => p.id === data.candidateId);
if (program) {
if (typeof data.programVotes === 'number') {
program.votes = data.programVotes;
} else if (typeof data.delta === 'number') {
program.votes += data.delta;
}
}
if (typeof data.totalVotes === 'number') {
totalVotes.value = data.totalVotes;
}
});
socket.value = socketInstance as GalaSocket;
}
function syncFromServer(state: AdminState) {
systemPhase.value = state.systemPhase;
votingOpen.value = state.voting.subPhase === 'OPEN';
votingPaused.value = state.voting.subPhase === 'PAUSED';
totalVotes.value = state.voting.totalVotes;
programs.value = state.voting.programs || [];
awards.value = state.voting.awards || [];
allowLateCatch.value = state.voting.allowLateCatch ?? true;
currentProgramId.value = state.voting.currentProgramId || null;
lotteryRound.value = state.lottery.round;
lotterySubPhase.value = state.lottery.subPhase;
currentWinners.value = state.lottery.currentWinners;
musicPlaying.value = state.music.isPlaying;
musicTrack.value = state.music.track;
saveState();
}
function disconnect() {
if (socket.value) {
socket.value.disconnect();
socket.value = null;
}
isConnected.value = false;
isConnecting.value = false;
}
// Admin Actions
function setPhase(phase: SystemPhase) {
if (!socket.value?.connected) return;
pendingAction.value = 'phase_change';
socket.value.emit(SOCKET_EVENTS.ADMIN_PHASE_CHANGE as any, {
phase,
timestamp: Date.now(),
}, (response: any) => {
pendingAction.value = null;
if (response.success) {
systemPhase.value = phase;
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || 'Failed to change phase';
}
});
}
function controlVoting(action: 'open' | 'close' | 'pause' | 'resume') {
if (!socket.value?.connected) return;
pendingAction.value = `voting_${action}`;
socket.value.emit(SOCKET_EVENTS.ADMIN_VOTING_CONTROL as any, {
action,
timestamp: Date.now(),
} as VotingControlPayload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (action === 'open') {
votingOpen.value = true;
votingPaused.value = false;
} else if (action === 'close') {
votingOpen.value = false;
votingPaused.value = false;
} else if (action === 'pause') {
votingPaused.value = true;
} else if (action === 'resume') {
votingPaused.value = false;
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || `Failed to ${action} voting`;
}
});
}
function controlLottery(action: LotteryControlPayload['action'], winnerIds?: string[]) {
if (!socket.value?.connected) return;
pendingAction.value = `lottery_${action}`;
const payload: LotteryControlPayload = {
action,
round: lotteryRound.value,
timestamp: Date.now(),
};
if (winnerIds) payload.winnerIds = winnerIds;
socket.value.emit(SOCKET_EVENTS.ADMIN_LOTTERY_CONTROL as any, payload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (action === 'start_galaxy') {
lotterySubPhase.value = 'GALAXY';
} else if (action === 'start_storm') {
lotterySubPhase.value = 'STORM';
stormStartedAt.value = Date.now();
} else if (action === 'stop_reveal') {
lotterySubPhase.value = 'REVEAL';
if (response.data?.winners) {
currentWinners.value = response.data.winners;
}
} else if (action === 'next_round') {
lotteryRound.value = Math.min(4, lotteryRound.value + 1) as LotteryRound;
lotterySubPhase.value = 'IDLE';
currentWinners.value = [];
stormStartedAt.value = null;
} else if (action === 'reset') {
lotteryRound.value = 1;
lotterySubPhase.value = 'IDLE';
currentWinners.value = [];
stormStartedAt.value = null;
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || `Failed to ${action}`;
}
});
}
function emergencyReset(scope: 'all' | 'voting' | 'lottery', confirmCode: string) {
if (!socket.value?.connected) return;
if (confirmCode !== 'RESET') {
lastError.value = 'Invalid confirmation code';
return;
}
pendingAction.value = 'emergency_reset';
socket.value.emit(SOCKET_EVENTS.ADMIN_EMERGENCY_RESET as any, {
scope,
confirmCode,
timestamp: Date.now(),
} as EmergencyResetPayload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (scope === 'all' || scope === 'voting') {
votingOpen.value = false;
votingPaused.value = false;
totalVotes.value = 0;
}
if (scope === 'all' || scope === 'lottery') {
lotteryRound.value = 1;
lotterySubPhase.value = 'IDLE';
currentWinners.value = [];
stormStartedAt.value = null;
}
if (scope === 'all') {
systemPhase.value = 'IDLE';
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || 'Emergency reset failed';
}
});
}
function controlMusic(action: 'play' | 'stop' | 'change_track', track?: MusicControlPayload['track']) {
if (!socket.value?.connected) return;
pendingAction.value = `music_${action}`;
socket.value.emit(SOCKET_EVENTS.ADMIN_MUSIC_CONTROL as any, {
action,
track,
} as MusicControlPayload, (response: any) => {
pendingAction.value = null;
if (response.success) {
if (action === 'play') {
musicPlaying.value = true;
if (track) musicTrack.value = track;
} else if (action === 'stop') {
musicPlaying.value = false;
} else if (action === 'change_track' && track) {
musicTrack.value = track;
}
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || `Failed to ${action} music`;
}
});
}
function clearError() {
lastError.value = null;
}
return {
// Connection
isConnected,
isConnecting,
connectionStatus,
latency,
onlineUsers,
// State
systemPhase,
votingOpen,
votingPaused,
totalVotes,
programs,
allowLateCatch,
currentProgramId,
awards,
lotteryRound,
lotterySubPhase,
stormStartedAt,
currentWinners,
musicPlaying,
musicTrack,
// Computed
currentPrize,
canStopLottery,
stopButtonCountdown,
// UI State
pendingAction,
lastError,
lastActionTime,
// Actions
connect,
disconnect,
setPhase,
controlVoting,
controlLottery,
emergencyReset,
controlMusic,
clearError,
getSocket: () => socket.value,
};
});