## 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>
375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
import { defineStore } from 'pinia';
|
||
import { ref, computed } from 'vue';
|
||
import { useConnectionStore } from './connection';
|
||
import { showToast } from 'vant';
|
||
|
||
// 奖项配置接口(从服务器同步)
|
||
export interface AwardConfig {
|
||
id: string;
|
||
name: string;
|
||
icon: string;
|
||
order: number;
|
||
remark?: string;
|
||
}
|
||
|
||
// 节目接口
|
||
export interface VotingProgram {
|
||
id: string;
|
||
name: string;
|
||
teamName: string;
|
||
performer?: string;
|
||
order: number;
|
||
remark?: string;
|
||
status: 'pending' | 'voting' | 'completed';
|
||
votes: number;
|
||
}
|
||
|
||
interface PendingVote {
|
||
awardId: string;
|
||
programId: string;
|
||
timestamp: number;
|
||
}
|
||
|
||
export const useVotingStore = defineStore('voting', () => {
|
||
const connectionStore = useConnectionStore();
|
||
|
||
// ============================================================================
|
||
// 奖项票系统:每个奖项一张票,票数 = 奖项数 = 节目数
|
||
// ============================================================================
|
||
|
||
// 奖项列表(从服务器同步)
|
||
const awards = ref<AwardConfig[]>([]);
|
||
|
||
// 已投票记录:awardId -> programId
|
||
const tickets = ref<Record<string, string | null>>({});
|
||
|
||
// Server-synced voting state
|
||
const votingOpen = ref(false);
|
||
const votingPaused = ref(false);
|
||
const programs = ref<VotingProgram[]>([]);
|
||
const currentProgramId = ref<string | null>(null);
|
||
const allowLateCatch = ref(true);
|
||
|
||
// 当前选中的奖项(准备投票)
|
||
const selectedAwardId = ref<string | null>(null);
|
||
|
||
// Pending votes (optimistic updates waiting for server confirmation)
|
||
const pendingVotes = ref<Map<string, PendingVote>>(new Map());
|
||
|
||
// Animation state for cards
|
||
const stampingCard = ref<string | null>(null);
|
||
|
||
// ============================================================================
|
||
// Computed
|
||
// ============================================================================
|
||
|
||
// 已使用的票数
|
||
const usedTicketCount = computed(() => {
|
||
return Object.values(tickets.value).filter(v => v !== null).length;
|
||
});
|
||
|
||
// 总票数 = 奖项数
|
||
const totalTicketCount = computed(() => awards.value.length);
|
||
|
||
// 剩余票数
|
||
const remainingTicketCount = computed(() =>
|
||
Math.max(0, totalTicketCount.value - usedTicketCount.value)
|
||
);
|
||
|
||
// 是否有选中奖项
|
||
const isAwardSelected = computed(() => selectedAwardId.value !== null);
|
||
|
||
// 获取选中的奖项信息
|
||
const selectedAward = computed(() => {
|
||
if (!selectedAwardId.value) return null;
|
||
return awards.value.find(a => a.id === selectedAwardId.value) || null;
|
||
});
|
||
|
||
// 已使用的奖项列表
|
||
const usedAwards = computed(() => {
|
||
return awards.value.filter(a => tickets.value[a.id] !== null && tickets.value[a.id] !== undefined);
|
||
});
|
||
|
||
// 可用的奖项列表
|
||
const availableAwards = computed(() => {
|
||
return awards.value.filter(a => tickets.value[a.id] === null || tickets.value[a.id] === undefined);
|
||
});
|
||
|
||
// ============================================================================
|
||
// 奖项票操作
|
||
// ============================================================================
|
||
|
||
// 初始化票据状态(当奖项列表更新时)
|
||
function initTickets() {
|
||
const newTickets: Record<string, string | null> = {};
|
||
for (const award of awards.value) {
|
||
newTickets[award.id] = tickets.value[award.id] ?? null;
|
||
}
|
||
tickets.value = newTickets;
|
||
}
|
||
|
||
// 检查某个奖项是否已使用
|
||
function isAwardUsed(awardId: string): boolean {
|
||
return tickets.value[awardId] !== null && tickets.value[awardId] !== undefined;
|
||
}
|
||
|
||
// 检查某个节目是否已获得任何奖项
|
||
function getProgramAward(programId: string): AwardConfig | null {
|
||
for (const award of awards.value) {
|
||
if (tickets.value[award.id] === programId) {
|
||
return award;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 选中一个奖项
|
||
function selectAward(awardId: string) {
|
||
if (isAwardUsed(awardId)) {
|
||
const award = awards.value.find(a => a.id === awardId);
|
||
showToast({ message: `${award?.name || '该奖项'}已投出`, position: 'bottom' });
|
||
return;
|
||
}
|
||
selectedAwardId.value = awardId;
|
||
}
|
||
|
||
// 取消选中
|
||
function deselectAward() {
|
||
selectedAwardId.value = null;
|
||
}
|
||
|
||
// Trigger haptic feedback
|
||
function triggerHaptic() {
|
||
if ('vibrate' in navigator) {
|
||
navigator.vibrate(50);
|
||
}
|
||
}
|
||
|
||
// 投票:将选中的奖项投给某个节目
|
||
async function castVote(programId: string): Promise<boolean> {
|
||
const awardId = selectedAwardId.value;
|
||
if (!awardId) {
|
||
showToast({ message: '请先选择一个奖项', position: 'bottom' });
|
||
return false;
|
||
}
|
||
|
||
// 检查是否已为该节目投过票(任何奖项)
|
||
const existingAward = getProgramAward(programId);
|
||
if (existingAward) {
|
||
showToast({ message: `已将${existingAward.name}投给该节目`, position: 'bottom' });
|
||
return false;
|
||
}
|
||
|
||
// Optimistic update
|
||
const previousValue = tickets.value[awardId];
|
||
tickets.value[awardId] = programId;
|
||
stampingCard.value = programId;
|
||
|
||
// Trigger haptic
|
||
triggerHaptic();
|
||
|
||
// Create pending vote record
|
||
const voteKey = `${awardId}:${programId}`;
|
||
pendingVotes.value.set(voteKey, {
|
||
awardId,
|
||
programId,
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
// Clear selection
|
||
selectedAwardId.value = null;
|
||
|
||
// Call backend
|
||
try {
|
||
const socket = connectionStore.getSocket();
|
||
if (!socket) {
|
||
throw new Error('Not connected');
|
||
}
|
||
|
||
const localId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||
|
||
const result = await new Promise<{ success: boolean; error?: string; message?: string }>((resolve) => {
|
||
const timeoutId = setTimeout(() => {
|
||
resolve({ success: false, error: 'TIMEOUT', message: '网络超时,请重试' });
|
||
}, 10000);
|
||
|
||
socket.emit('vote:submit' as any, {
|
||
candidateId: programId,
|
||
category: awardId, // 奖项ID作为category
|
||
clientTimestamp: Date.now(),
|
||
localId,
|
||
}, (response: any) => {
|
||
clearTimeout(timeoutId);
|
||
resolve(response);
|
||
});
|
||
});
|
||
|
||
if (!result.success) {
|
||
const errorMessage = result.message || '投票失败,请重试';
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
// Success - remove from pending
|
||
pendingVotes.value.delete(voteKey);
|
||
return true;
|
||
|
||
} catch (error) {
|
||
// Revert optimistic update
|
||
tickets.value[awardId] = previousValue;
|
||
pendingVotes.value.delete(voteKey);
|
||
|
||
const errorMessage = error instanceof Error ? error.message : '投票失败,请重试';
|
||
showToast({
|
||
message: errorMessage,
|
||
position: 'bottom',
|
||
type: 'fail',
|
||
});
|
||
return false;
|
||
|
||
} finally {
|
||
// Clear animation state after delay
|
||
setTimeout(() => {
|
||
stampingCard.value = null;
|
||
}, 600);
|
||
}
|
||
}
|
||
|
||
// 撤销投票
|
||
async function revokeVote(awardId: string): Promise<boolean> {
|
||
const programId = tickets.value[awardId];
|
||
if (!programId) return false;
|
||
|
||
// Optimistic update
|
||
tickets.value[awardId] = null;
|
||
|
||
try {
|
||
const socket = connectionStore.getSocket();
|
||
if (!socket) throw new Error('Not connected');
|
||
|
||
const result = await new Promise<{ success: boolean }>((resolve) => {
|
||
socket.emit('vote:revoke' as any, { awardId }, (response: any) => {
|
||
resolve(response);
|
||
});
|
||
setTimeout(() => resolve({ success: false }), 10000);
|
||
});
|
||
|
||
if (!result.success) throw new Error('Revoke failed');
|
||
return true;
|
||
|
||
} catch {
|
||
// Revert
|
||
tickets.value[awardId] = programId;
|
||
showToast({ message: '撤销失败', position: 'bottom', type: 'fail' });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Sync state from server
|
||
function syncFromServer(serverTickets: Record<string, string | null>) {
|
||
tickets.value = { ...serverTickets };
|
||
}
|
||
|
||
// Sync awards from server
|
||
function syncAwards(serverAwards: AwardConfig[]) {
|
||
awards.value = serverAwards;
|
||
initTickets();
|
||
}
|
||
|
||
// Sync voting state from AdminState
|
||
function syncVotingState(state: {
|
||
votingOpen?: boolean;
|
||
votingPaused?: boolean;
|
||
programs?: VotingProgram[];
|
||
currentProgramId?: string | null;
|
||
allowLateCatch?: boolean;
|
||
awards?: AwardConfig[];
|
||
}) {
|
||
if (state.votingOpen !== undefined) votingOpen.value = state.votingOpen;
|
||
if (state.votingPaused !== undefined) votingPaused.value = state.votingPaused;
|
||
if (state.programs !== undefined) programs.value = state.programs;
|
||
if (state.currentProgramId !== undefined) currentProgramId.value = state.currentProgramId;
|
||
if (state.allowLateCatch !== undefined) allowLateCatch.value = state.allowLateCatch;
|
||
if (state.awards !== undefined) syncAwards(state.awards);
|
||
}
|
||
|
||
// Check if voting is allowed for a specific program
|
||
function canVoteForProgram(programId: string): { allowed: boolean; reason?: string } {
|
||
if (!votingOpen.value) {
|
||
return { allowed: false, reason: '投票尚未开始' };
|
||
}
|
||
if (votingPaused.value) {
|
||
return { allowed: false, reason: '投票已暂停' };
|
||
}
|
||
// 检查是否已为该节目投过票
|
||
const existingAward = getProgramAward(programId);
|
||
if (existingAward) {
|
||
return { allowed: false, reason: `已投${existingAward.name}` };
|
||
}
|
||
return { allowed: true };
|
||
}
|
||
|
||
// Handle real-time vote updates
|
||
function handleVoteUpdate(data: { candidateId: string; newCount: number; totalVotes: number }) {
|
||
const program = programs.value.find(p => p.id === data.candidateId);
|
||
if (program) {
|
||
program.votes = data.newCount;
|
||
}
|
||
}
|
||
|
||
// Get current voting program
|
||
const currentProgram = computed(() => {
|
||
if (!currentProgramId.value) return null;
|
||
return programs.value.find(p => p.id === currentProgramId.value) || null;
|
||
});
|
||
|
||
// Get votable programs (voting or completed with late catch)
|
||
const votablePrograms = computed(() => {
|
||
return programs.value.filter(p => {
|
||
if (p.status === 'voting') return true;
|
||
if (p.status === 'completed' && allowLateCatch.value) return true;
|
||
return false;
|
||
});
|
||
});
|
||
|
||
return {
|
||
// 奖项票状态
|
||
awards,
|
||
tickets,
|
||
selectedAwardId,
|
||
selectedAward,
|
||
pendingVotes,
|
||
stampingCard,
|
||
|
||
// Computed
|
||
usedTicketCount,
|
||
totalTicketCount,
|
||
remainingTicketCount,
|
||
isAwardSelected,
|
||
usedAwards,
|
||
availableAwards,
|
||
|
||
// 操作
|
||
initTickets,
|
||
isAwardUsed,
|
||
getProgramAward,
|
||
selectAward,
|
||
deselectAward,
|
||
castVote,
|
||
revokeVote,
|
||
syncFromServer,
|
||
syncAwards,
|
||
|
||
// 投票状态
|
||
votingOpen,
|
||
votingPaused,
|
||
programs,
|
||
currentProgramId,
|
||
allowLateCatch,
|
||
currentProgram,
|
||
votablePrograms,
|
||
syncVotingState,
|
||
canVoteForProgram,
|
||
handleVoteUpdate,
|
||
};
|
||
});
|