Files
company-celebration/packages/client-mobile/src/stores/voting.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

375 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
});