459 lines
14 KiB
TypeScript
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,
|
|
};
|
|
});
|