feat: enhance lottery system with participant import and prize config

- Fix ES module import issue in admin.service.ts (require -> import)
- Fix lottery reveal ghosting by hiding name particles on complete
- Add participant import from Excel with tag calculation
- Add prize configuration service with JSON persistence
- Constrain winners overlay to scroll area dimensions
- Fix macOS lsof syntax in stop script
- Add HorseRace view and renderer (WIP)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-23 12:20:45 +08:00
parent 35d77cbb22
commit a442d050e4
23 changed files with 2523 additions and 325 deletions

View File

@@ -3,7 +3,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
import PostcardItem from './PostcardItem.vue';
import type { VotingProgram } from '@gala/shared/types';
interface Props {
export interface Props {
programs: VotingProgram[];
columns?: number;
rows?: number;

View File

@@ -15,7 +15,7 @@ const TICKET_TYPE_NAMES: Record<string, string> = {
potential: '最具潜力',
};
interface Props {
export interface Props {
id: string;
name: string;
teamName: string;

View File

@@ -7,6 +7,7 @@ import type {
DrawSpinPayload,
DrawWinnerPayload,
VoteUpdatePayload,
AdminState,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
@@ -42,6 +43,7 @@ export interface VoteEvent {
export type VoteUpdateCallback = (event: VoteEvent) => void;
export type LotteryStateCallback = (state: LotteryState) => void;
export type AdminStateCallback = (state: AdminState) => void;
// ============================================================================
// Composable
@@ -68,6 +70,10 @@ export function useSocketClient() {
// Event callbacks
const voteCallbacks = new Set<VoteUpdateCallback>();
const lotteryCallbacks = new Set<LotteryStateCallback>();
const adminCallbacks = new Set<AdminStateCallback>();
// Admin state (from server)
const adminState = ref<AdminState | null>(null);
// Heartbeat
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
@@ -142,6 +148,16 @@ export function useSocketClient() {
voteCallbacks.forEach(cb => cb(event));
});
// Admin state sync (from admin control panel)
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
adminState.value = state;
// Notify all registered callbacks
adminCallbacks.forEach(cb => cb(state));
});
// Request initial admin state
socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
// Lottery events
socketInstance.on('draw:started' as any, (data: DrawStartPayload) => {
lotteryState.value = {
@@ -226,6 +242,11 @@ export function useSocketClient() {
return () => lotteryCallbacks.delete(callback);
}
function onAdminStateChange(callback: AdminStateCallback): () => void {
adminCallbacks.add(callback);
return () => adminCallbacks.delete(callback);
}
function notifyLotteryCallbacks(): void {
lotteryCallbacks.forEach(cb => cb(lotteryState.value));
}
@@ -268,12 +289,14 @@ export function useSocketClient() {
onlineUsers: readonly(onlineUsers),
latency: readonly(latency),
lotteryState: readonly(lotteryState),
adminState: readonly(adminState),
// Methods
connect,
disconnect,
onVoteUpdate,
onLotteryStateChange,
onAdminStateChange,
startLottery,
stopLottery,

View File

@@ -0,0 +1,382 @@
import { Application, Container, Graphics, Text, TextStyle, Ticker } from 'pixi.js';
// ============================================================================
// Constants
// ============================================================================
const COLORS = {
gold: 0xf0c239,
goldLight: 0xffd700,
goldDark: 0xd4a84b,
red: 0xc21f30,
trackLine: 0x333333,
trackBg: 0x1a1a1a,
textMuted: 0x888888,
};
const Easing = {
easeOutQuart: (t: number): number => 1 - Math.pow(1 - t, 4),
easeInOutCubic: (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
};
// ============================================================================
// Types
// ============================================================================
export interface RaceHorse {
id: string;
name: string;
votes: number;
trackIndex: number;
// Animation state
currentX: number;
targetX: number;
glowIntensity: number;
targetGlowIntensity: number;
particles: Array<{ x: number; y: number; alpha: number; speed: number }>;
}
export interface HorseRaceConfig {
trackCount: number;
trackHeight: number;
trackPadding: number;
minX: number;
maxX: number;
horseSize: number;
}
// ============================================================================
// HorseRaceRenderer Class
// ============================================================================
export class HorseRaceRenderer {
private app: Application;
private container: Container;
private trackLayer: Graphics;
private horseLayer: Graphics;
private labelLayer: Container;
private horses: Map<string, RaceHorse> = new Map();
private maxVotes: number = 1;
private config: HorseRaceConfig;
private isDestroyed = false;
private time = 0;
constructor() {
this.app = new Application();
this.container = new Container();
this.trackLayer = new Graphics();
this.horseLayer = new Graphics();
this.labelLayer = new Container();
this.config = {
trackCount: 8,
trackHeight: 80,
trackPadding: 20,
minX: 150,
maxX: 0, // Will be set on init
horseSize: 20,
};
}
async init(canvas: HTMLCanvasElement): Promise<void> {
await this.app.init({
canvas,
width: window.innerWidth,
height: window.innerHeight,
background: COLORS.trackBg,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
this.config.maxX = this.app.screen.width - 100;
this.container.addChild(this.trackLayer);
this.container.addChild(this.horseLayer);
this.container.addChild(this.labelLayer);
this.app.stage.addChild(this.container);
this.app.ticker.add(this.update.bind(this));
window.addEventListener('resize', this.handleResize.bind(this));
this.drawTracks();
}
// ============================================================================
// Public API
// ============================================================================
/**
* Set the list of programs (horses) to display
*/
setPrograms(programs: Array<{ id: string; name: string; votes: number }>): void {
// Update max votes
this.maxVotes = Math.max(1, ...programs.map((p) => p.votes));
programs.forEach((program, index) => {
const trackIndex = index % this.config.trackCount;
const trackY = this.getTrackY(trackIndex);
if (this.horses.has(program.id)) {
// Update existing horse
const horse = this.horses.get(program.id)!;
horse.votes = program.votes;
horse.targetX = this.calculateHorseX(program.votes);
horse.targetGlowIntensity = this.calculateGlowIntensity(program.votes);
} else {
// Create new horse
const startX = this.config.minX;
const horse: RaceHorse = {
id: program.id,
name: program.name,
votes: program.votes,
trackIndex,
currentX: startX,
targetX: this.calculateHorseX(program.votes),
glowIntensity: 0,
targetGlowIntensity: this.calculateGlowIntensity(program.votes),
particles: [],
};
this.horses.set(program.id, horse);
// Create label
this.createHorseLabel(horse, trackY);
}
});
this.drawTracks();
}
/**
* Update a single program's votes
*/
updateVotes(programId: string, newVotes: number): void {
const horse = this.horses.get(programId);
if (!horse) return;
horse.votes = newVotes;
// Recalculate max votes
this.maxVotes = Math.max(1, ...Array.from(this.horses.values()).map((h) => h.votes));
// Update all horses' target positions
this.horses.forEach((h) => {
h.targetX = this.calculateHorseX(h.votes);
h.targetGlowIntensity = this.calculateGlowIntensity(h.votes);
});
// Add burst particles for the updated horse
this.addParticleBurst(horse);
}
// ============================================================================
// Calculation Helpers
// ============================================================================
private getTrackY(trackIndex: number): number {
const totalHeight = this.config.trackCount * this.config.trackHeight;
const startY = (this.app.screen.height - totalHeight) / 2;
return startY + trackIndex * this.config.trackHeight + this.config.trackHeight / 2;
}
private calculateHorseX(votes: number): number {
const progress = this.maxVotes > 0 ? votes / this.maxVotes : 0;
return this.config.minX + (this.config.maxX - this.config.minX) * progress;
}
private calculateGlowIntensity(votes: number): number {
const progress = this.maxVotes > 0 ? votes / this.maxVotes : 0;
return 0.3 + progress * 0.7;
}
// ============================================================================
// Drawing
// ============================================================================
private drawTracks(): void {
this.trackLayer.clear();
for (let i = 0; i < this.config.trackCount; i++) {
const y = this.getTrackY(i);
// Track background stripe (alternating)
const stripeAlpha = i % 2 === 0 ? 0.05 : 0.02;
this.trackLayer.rect(
0,
y - this.config.trackHeight / 2,
this.app.screen.width,
this.config.trackHeight
);
this.trackLayer.fill({ color: 0xffffff, alpha: stripeAlpha });
// Track line
this.trackLayer.moveTo(this.config.minX - 20, y);
this.trackLayer.lineTo(this.config.maxX + 50, y);
this.trackLayer.stroke({ color: COLORS.trackLine, alpha: 0.3, width: 1 });
// Start line
this.trackLayer.moveTo(this.config.minX, y - 20);
this.trackLayer.lineTo(this.config.minX, y + 20);
this.trackLayer.stroke({ color: COLORS.gold, alpha: 0.5, width: 2 });
}
// Finish line
const finishX = this.config.maxX;
this.trackLayer.moveTo(finishX, this.getTrackY(0) - this.config.trackHeight / 2);
this.trackLayer.lineTo(finishX, this.getTrackY(this.config.trackCount - 1) + this.config.trackHeight / 2);
this.trackLayer.stroke({ color: COLORS.gold, alpha: 0.8, width: 3 });
// Finish checkered pattern
const checkerSize = 10;
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 3; col++) {
if ((row + col) % 2 === 0) {
this.trackLayer.rect(
finishX + col * checkerSize,
this.getTrackY(0) - this.config.trackHeight / 2 + row * checkerSize,
checkerSize,
checkerSize
);
this.trackLayer.fill({ color: COLORS.gold, alpha: 0.6 });
}
}
}
}
private createHorseLabel(horse: RaceHorse, y: number): void {
const style = new TextStyle({
fontFamily: 'SimSun, Songti SC, serif',
fontSize: 16,
fill: COLORS.textMuted,
align: 'right',
});
const label = new Text({ text: horse.name, style });
label.anchor.set(1, 0.5);
label.x = this.config.minX - 30;
label.y = y;
label.name = `label-${horse.id}`;
this.labelLayer.addChild(label);
}
private drawHorse(horse: RaceHorse): void {
const y = this.getTrackY(horse.trackIndex);
const x = horse.currentX;
const size = this.config.horseSize;
const intensity = horse.glowIntensity;
// Outer glow rings
for (let i = 4; i >= 0; i--) {
const ratio = i / 4;
const glowRadius = size * (1.5 + ratio * 2) * intensity;
const alpha = (1 - ratio) * 0.2 * intensity;
this.horseLayer.circle(x, y, glowRadius);
this.horseLayer.fill({ color: COLORS.gold, alpha });
}
// Main body (gold orb)
this.horseLayer.circle(x, y, size);
this.horseLayer.fill({ color: COLORS.gold, alpha: 0.9 });
// Inner bright core
this.horseLayer.circle(x, y, size * 0.6);
this.horseLayer.fill({ color: COLORS.goldLight, alpha: 1 });
// White hot center
this.horseLayer.circle(x, y, size * 0.3);
this.horseLayer.fill({ color: 0xffffff, alpha: 0.8 });
// Draw particles
horse.particles.forEach((p) => {
this.horseLayer.circle(p.x, y + (Math.random() - 0.5) * 10, 3);
this.horseLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
});
}
private addParticleBurst(horse: RaceHorse): void {
const y = this.getTrackY(horse.trackIndex);
for (let i = 0; i < 8; i++) {
horse.particles.push({
x: horse.currentX,
y,
alpha: 1,
speed: 2 + Math.random() * 3,
});
}
}
// ============================================================================
// Animation Loop
// ============================================================================
private update(ticker: Ticker): void {
if (this.isDestroyed) return;
this.time += ticker.deltaMS;
this.horseLayer.clear();
this.horses.forEach((horse) => {
// Smoothly interpolate position
const dx = horse.targetX - horse.currentX;
horse.currentX += dx * 0.08; // Smooth easing
// Smoothly interpolate glow
const dg = horse.targetGlowIntensity - horse.glowIntensity;
horse.glowIntensity += dg * 0.1;
// Update particles (trail effect)
horse.particles = horse.particles.filter((p) => {
p.x -= p.speed;
p.alpha -= 0.02;
return p.alpha > 0;
});
// Add trail particles when moving fast
if (Math.abs(dx) > 5 && Math.random() > 0.7) {
const y = this.getTrackY(horse.trackIndex);
horse.particles.push({
x: horse.currentX - this.config.horseSize,
y,
alpha: 0.6,
speed: 1 + Math.random() * 2,
});
}
this.drawHorse(horse);
});
}
// ============================================================================
// Lifecycle
// ============================================================================
private handleResize(): void {
if (this.isDestroyed) return;
this.app.renderer.resize(window.innerWidth, window.innerHeight);
this.config.maxX = this.app.screen.width - 100;
// Update label positions
this.horses.forEach((horse) => {
const label = this.labelLayer.getChildByName(`label-${horse.id}`) as Text;
if (label) {
label.y = this.getTrackY(horse.trackIndex);
}
horse.targetX = this.calculateHorseX(horse.votes);
});
this.drawTracks();
}
destroy(): void {
this.isDestroyed = true;
window.removeEventListener('resize', this.handleResize.bind(this));
this.horses.clear();
this.app.destroy(true, { children: true });
}
}

View File

@@ -237,6 +237,19 @@ export class LotteryMachine {
* Start the lottery - transition to Galaxy phase
*/
startGalaxy(): void {
// Clear previous round's reveal display
this.winners = [];
this.confettiParticles = [];
this.revealContainer.removeChildren();
this.scrollContainer.removeChildren();
this.confettiContainer.removeChildren();
this.dimOverlay.clear();
// Reset particles
this.nameParticles.forEach((p) => {
p.isWinner = false;
});
this.phase = 'galaxy';
this.phaseTime = 0;
this.onPhaseChange?.('galaxy');
@@ -542,8 +555,18 @@ export class LotteryMachine {
// Spawn confetti when reveal is complete
if (this.revealProgress >= 1 && this.phase === 'reveal') {
this.phase = 'complete';
// Hide all name particles to prevent ghosting
this.nameParticles.forEach((p) => {
if (p.text) {
p.text.alpha = 0;
p.text.visible = false;
}
});
this.spawnConfettiBurst();
this.createWinnerDisplay();
// Don't create Pixi winner display - Vue overlay handles it
// this.createWinnerDisplay();
this.onWinnersRevealed?.(this.winners);
this.onPhaseChange?.('complete');
}

View File

@@ -51,6 +51,12 @@ const router = createRouter({
component: () => import('../views/VoteResultsView.vue'),
meta: { title: '年会大屏 - 投票结果' },
},
{
path: '/screen/horse-race',
name: 'screen-horse-race',
component: () => import('../views/HorseRaceView.vue'),
meta: { title: '年会大屏 - 赛马热度' },
},
// Legacy routes (redirect to new paths)
{ path: '/voting', redirect: '/screen/voting' },

View File

@@ -43,6 +43,24 @@ export const useAdminStore = defineStore('admin', () => {
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');
@@ -67,14 +85,14 @@ export const useAdminStore = defineStore('admin', () => {
const canStopLottery = computed(() => {
if (lotterySubPhase.value !== 'STORM') return false;
if (!stormStartedAt.value) return false;
// 3-second safety delay
return Date.now() - stormStartedAt.value >= 3000;
// 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 = Date.now() - stormStartedAt.value;
const elapsed = currentTime.value - stormStartedAt.value;
return Math.max(0, Math.ceil((3000 - elapsed) / 1000));
});
@@ -86,6 +104,7 @@ export const useAdminStore = defineStore('admin', () => {
votingPaused: votingPaused.value,
lotteryRound: lotteryRound.value,
lotterySubPhase: lotterySubPhase.value,
stormStartedAt: stormStartedAt.value,
currentWinners: currentWinners.value,
musicPlaying: musicPlaying.value,
musicTrack: musicTrack.value,
@@ -104,9 +123,15 @@ export const useAdminStore = defineStore('admin', () => {
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);
@@ -115,11 +140,20 @@ export const useAdminStore = defineStore('admin', () => {
// Watch for state changes and persist
watch(
[systemPhase, votingOpen, votingPaused, lotteryRound, lotterySubPhase, currentWinners, musicPlaying, musicTrack],
[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;

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminStore } from '../stores/admin';
import { PRIZE_CONFIG } from '@gala/shared/types';
import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
const router = useRouter();
const admin = useAdminStore();
@@ -12,9 +13,120 @@ const confirmResetCode = ref('');
const showResetModal = ref(false);
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
// Countdown timer for stop button
const countdownDisplay = ref(3);
let countdownInterval: ReturnType<typeof setInterval> | null = null;
// Prize configuration state
const showPrizeConfig = ref(false);
const prizeConfigLoading = ref(false);
const prizeConfigSaving = ref(false);
const editingPrizes = ref<PrizeConfig[]>([]);
// Load prize configuration from server
async function loadPrizeConfig() {
prizeConfigLoading.value = true;
try {
const res = await fetch('/api/admin/prizes');
const data = await res.json();
if (data.success && data.data?.prizes) {
editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p }));
}
} catch (e) {
console.error('Failed to load prize config:', e);
} finally {
prizeConfigLoading.value = false;
}
}
// Save prize configuration to server
async function savePrizeConfig() {
prizeConfigSaving.value = true;
try {
const res = await fetch('/api/admin/prizes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prizes: editingPrizes.value }),
});
const data = await res.json();
if (data.success) {
showPrizeConfig.value = false;
} else {
alert('保存失败: ' + (data.error || '未知错误'));
}
} catch (e) {
alert('保存失败: ' + (e as Error).message);
} finally {
prizeConfigSaving.value = false;
}
}
// Open prize configuration modal
function openPrizeConfig() {
showPrizeConfig.value = true;
loadPrizeConfig();
}
// Participant import state
const importFile = ref<File | null>(null);
const importing = ref(false);
const importResult = ref<{
success: boolean;
totalCount: number;
importedCount: number;
tagDistribution: Record<string, number>;
errors: string[];
} | null>(null);
// Handle file selection
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
importFile.value = input.files[0];
}
}
// Import participants from Excel
async function importParticipants() {
if (!importFile.value) return;
importing.value = true;
importResult.value = null;
try {
const formData = new FormData();
formData.append('file', importFile.value);
const response = await fetch('/api/admin/participants/import', {
method: 'POST',
body: formData,
});
const data = await response.json();
// 确保保留 success 字段,后端返回结构为 { success: true, data: {...} }
importResult.value = {
success: data.success ?? data.data?.success ?? false,
totalCount: data.data?.totalCount ?? data.totalCount ?? 0,
importedCount: data.data?.importedCount ?? data.importedCount ?? 0,
tagDistribution: data.data?.tagDistribution ?? data.tagDistribution ?? {},
errors: data.data?.errors ?? data.errors ?? [],
};
} catch (error) {
importResult.value = {
success: false,
totalCount: 0,
importedCount: 0,
tagDistribution: {},
errors: [(error as Error).message],
};
} finally {
importing.value = false;
}
}
// Tag display names
const tagLabels: Record<string, string> = {
'6070': '60/70后',
'80': '80后',
'90': '90后',
'horse': '属马',
};
// Navigation
function goBack() {
@@ -85,15 +197,6 @@ function startGalaxy() {
function startStorm() {
admin.controlLottery('start_storm');
// Start countdown
countdownDisplay.value = 3;
countdownInterval = setInterval(() => {
countdownDisplay.value = Math.max(0, countdownDisplay.value - 1);
if (countdownDisplay.value === 0 && countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}, 1000);
}
function stopAndReveal() {
@@ -142,6 +245,11 @@ function playFanfare() {
// Computed helpers
const currentPrizeConfig = computed(() => {
// 优先使用从服务器加载的配置
if (editingPrizes.value.length > 0) {
return editingPrizes.value.find(p => p.round === admin.lotteryRound) || editingPrizes.value[0];
}
// 降级到硬编码配置
return PRIZE_CONFIG.find(p => p.round === admin.lotteryRound) || PRIZE_CONFIG[0];
});
@@ -223,12 +331,8 @@ const awardStats = computed(() => {
// Lifecycle
onMounted(() => {
admin.connect();
});
onUnmounted(() => {
if (countdownInterval) {
clearInterval(countdownInterval);
}
// 加载服务器奖项配置
loadPrizeConfig();
});
</script>
@@ -330,6 +434,37 @@ onUnmounted(() => {
<div v-if="currentPrizeConfig.zodiacFilter" class="prize-filter">
限定: 属马
</div>
<button class="prize-config-btn" @click="openPrizeConfig" title="配置奖项">
配置
</button>
</div>
<!-- Prize Configuration Modal -->
<div v-if="showPrizeConfig" class="modal-overlay" @click.self="showPrizeConfig = false">
<div class="modal-content prize-config-modal">
<div class="modal-header">
<h3>奖项配置</h3>
<button class="close-btn" @click="showPrizeConfig = false">×</button>
</div>
<div class="modal-body">
<div v-if="prizeConfigLoading" class="loading">加载中...</div>
<div v-else class="prize-list">
<div v-for="prize in editingPrizes" :key="prize.round" class="prize-edit-item">
<div class="prize-round"> {{ prize.round }} </div>
<input v-model="prize.level" placeholder="等级名称" class="prize-input" />
<input v-model="prize.name" placeholder="奖品名称" class="prize-input prize-input-wide" />
<input v-model.number="prize.winnerCount" type="number" min="1" class="prize-input prize-input-small" />
<span class="prize-unit"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="ctrl-btn outline" @click="showPrizeConfig = false">取消</button>
<button class="ctrl-btn primary" :disabled="prizeConfigSaving" @click="savePrizeConfig">
{{ prizeConfigSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
<!-- Lottery State Machine Controls -->
@@ -362,11 +497,29 @@ onUnmounted(() => {
@click="stopAndReveal"
>
<span v-if="!admin.canStopLottery" class="countdown">
{{ countdownDisplay }}s
{{ admin.stopButtonCountdown }}s
</span>
<span v-else>停止抽奖</span>
</button>
<!-- REVEAL State -->
<div v-if="admin.lotterySubPhase === 'REVEAL'" class="complete-controls">
<button
class="ctrl-btn primary"
:disabled="isLastRound || admin.pendingAction === 'lottery_next_round'"
@click="nextRound"
>
下一轮
</button>
<button
class="ctrl-btn outline"
:disabled="admin.pendingAction === 'lottery_reset'"
@click="resetLottery"
>
重置
</button>
</div>
<!-- COMPLETE State -->
<div v-if="admin.lotterySubPhase === 'COMPLETE'" class="complete-controls">
<button
@@ -414,6 +567,63 @@ onUnmounted(() => {
</div>
</section>
<!-- Section: Participant Import -->
<section class="control-section import-section">
<div class="section-header">
<h2>🎯 抽奖名单导入</h2>
<span v-if="importResult" class="section-status" :class="{ active: importResult.success }">
{{ importResult.success ? `已导入 ${importResult.importedCount} ` : '导入失败' }}
</span>
</div>
<div class="section-body">
<div class="import-controls">
<label class="file-input-wrapper">
<input
type="file"
accept=".xlsx,.xls"
@change="handleFileSelect"
class="file-input"
/>
<span class="file-input-label">
{{ importFile ? importFile.name : '选择 Excel 文件' }}
</span>
</label>
<button
class="ctrl-btn primary"
:disabled="!importFile || importing"
@click="importParticipants"
>
{{ importing ? '导入中...' : '开始导入' }}
</button>
</div>
<p class="import-hint">支持格式岗位 | 姓名 | 年份</p>
<!-- Import Result -->
<div v-if="importResult" class="import-result" :class="{ error: !importResult.success }">
<div v-if="importResult.success" class="result-stats">
<div class="stat-item">
<span class="stat-label">总行数</span>
<span class="stat-value">{{ importResult.totalCount }}</span>
</div>
<div class="stat-item">
<span class="stat-label">成功导入</span>
<span class="stat-value">{{ importResult.importedCount }}</span>
</div>
<div v-for="(count, tag) in importResult.tagDistribution" :key="tag" class="stat-item">
<span class="stat-label">{{ tagLabels[tag] || tag }}</span>
<span class="stat-value">{{ count }}</span>
</div>
</div>
<div v-if="importResult.errors.length > 0" class="result-errors">
<p v-for="(err, i) in importResult.errors.slice(0, 5)" :key="i" class="error-line">{{ err }}</p>
<p v-if="importResult.errors.length > 5" class="error-more">...还有 {{ importResult.errors.length - 5 }} 条错误</p>
</div>
</div>
</div>
</section>
<!-- Section C: Global Controls -->
<section class="control-section global-section">
<div class="section-header">
@@ -1375,4 +1585,169 @@ $admin-danger: #ef4444;
}
}
}
// Import Section Styles
.import-section {
grid-column: span 2;
}
.import-controls {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.file-input-wrapper {
position: relative;
display: inline-block;
.file-input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.file-input-label {
display: inline-block;
padding: 10px 20px;
background: #252525;
border: 1px dashed #444;
border-radius: 8px;
color: #888;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #666;
color: #ccc;
}
}
}
.import-hint {
font-size: 12px;
color: #666;
margin-bottom: 16px;
}
.import-result {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
padding: 16px;
&.error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
}
.result-stats {
display: flex;
flex-wrap: wrap;
gap: 24px;
.stat-item {
.stat-value {
font-size: 20px;
}
}
}
.result-errors {
margin-top: 12px;
.error-line {
font-size: 12px;
color: #ef4444;
margin-bottom: 4px;
}
.error-more {
font-size: 12px;
color: #888;
font-style: italic;
}
}
// Prize Config Button & Modal
.prize-config-btn {
margin-top: 12px;
padding: 6px 12px;
background: #333;
border: 1px solid #555;
border-radius: 6px;
color: #ccc;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #444;
border-color: #666;
}
}
.prize-config-modal {
width: 600px;
max-width: 90vw;
}
.prize-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.prize-edit-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #252525;
border-radius: 8px;
}
.prize-round {
min-width: 60px;
font-weight: bold;
color: #f59e0b;
}
.prize-input {
padding: 8px 12px;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-size: 14px;
&:focus {
outline: none;
border-color: #f59e0b;
}
}
.prize-input-wide {
flex: 1;
}
.prize-input-small {
width: 60px;
text-align: center;
}
.prize-unit {
color: #888;
font-size: 14px;
}
.loading {
text-align: center;
color: #888;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useDisplayStore } from '../stores/display';
import { HorseRaceRenderer } from '../pixi/HorseRaceRenderer';
import type { AdminState } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
const router = useRouter();
const displayStore = useDisplayStore();
// Canvas ref
const canvasRef = ref<HTMLCanvasElement | null>(null);
let renderer: HorseRaceRenderer | null = null;
// State
const votingOpen = ref(false);
const totalVotes = ref(0);
function goBack() {
router.push('/');
}
// Handle state sync from admin
function handleStateSync(state: AdminState) {
votingOpen.value = state.voting.subPhase === 'OPEN';
totalVotes.value = state.voting.totalVotes;
// Update renderer with programs
if (renderer && state.voting.programs) {
renderer.setPrograms(
state.voting.programs.map((p) => ({
id: p.id,
name: p.name,
votes: p.votes,
}))
);
}
}
// Handle real-time vote updates
function handleVoteUpdate(data: { candidateId: string; totalVotes: number }) {
if (renderer) {
renderer.updateVotes(data.candidateId, data.totalVotes);
}
// Update total votes
totalVotes.value = data.totalVotes;
}
onMounted(async () => {
await nextTick();
if (canvasRef.value) {
renderer = new HorseRaceRenderer();
await renderer.init(canvasRef.value);
}
const socket = displayStore.getSocket();
if (socket) {
socket.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, handleStateSync);
socket.on(SOCKET_EVENTS.VOTE_UPDATED as any, handleVoteUpdate);
// Request initial state
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
}
});
onUnmounted(() => {
if (renderer) {
renderer.destroy();
renderer = null;
}
const socket = displayStore.getSocket();
if (socket) {
socket.off(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, handleStateSync);
socket.off(SOCKET_EVENTS.VOTE_UPDATED as any, handleVoteUpdate);
}
});
</script>
<template>
<div class="horse-race-view">
<!-- Canvas Background -->
<canvas ref="canvasRef" class="race-canvas"></canvas>
<!-- Overlay UI -->
<div class="overlay-ui">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title">🏇 节目热度赛马</h1>
<div class="header-right">
<div class="vote-counter">
<span class="counter-label">总票数</span>
<span class="counter-value">{{ totalVotes.toLocaleString() }}</span>
</div>
<div class="status">
<span class="status-badge" :class="{ open: votingOpen }">
{{ votingOpen ? '投票进行中' : '投票未开始' }}
</span>
<span class="online-count">{{ displayStore.onlineUsers }} 人在线</span>
<span class="connection-dot" :class="{ connected: displayStore.isConnected }"></span>
</div>
</div>
</header>
<!-- Legend -->
<div class="legend">
<div class="legend-item">
<span class="legend-icon start"></span>
<span>起点</span>
</div>
<div class="legend-item">
<span class="legend-icon finish"></span>
<span>终点</span>
</div>
<div class="legend-item">
<span class="legend-icon horse"></span>
<span>热度光球</span>
</div>
</div>
<!-- Footer Hint -->
<div class="footer-hint">
<span>💡 每收到一票对应节目的马匹将向前推进</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$color-gold: #f0c239;
$color-red: #c21f30;
$color-text-muted: #888;
.horse-race-view {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: #0a0a0a;
}
.race-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.overlay-ui {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
pointer-events: none;
> * {
pointer-events: auto;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
.back-btn {
background: rgba(0, 0, 0, 0.5);
border: 1px solid $color-gold;
color: $color-gold;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
backdrop-filter: blur(10px);
&:hover {
background: rgba($color-gold, 0.2);
}
}
.title {
font-size: 32px;
font-family: 'SimSun', 'Songti SC', serif;
font-weight: bold;
color: $color-gold;
text-shadow: 0 2px 10px rgba($color-gold, 0.5);
}
.header-right {
display: flex;
align-items: center;
gap: 24px;
}
.vote-counter {
display: flex;
flex-direction: column;
align-items: flex-end;
background: rgba(0, 0, 0, 0.5);
padding: 8px 16px;
border-radius: 8px;
border: 1px solid rgba($color-gold, 0.3);
backdrop-filter: blur(10px);
.counter-label {
font-size: 12px;
color: $color-text-muted;
}
.counter-value {
font-size: 28px;
font-weight: bold;
color: $color-gold;
font-family: 'Courier New', monospace;
}
}
.status {
display: flex;
align-items: center;
gap: 12px;
.status-badge {
padding: 6px 16px;
border-radius: 4px;
font-size: 14px;
background: rgba(255, 255, 255, 0.1);
color: $color-text-muted;
border: 1px solid rgba(255, 255, 255, 0.2);
&.open {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
border-color: rgba(34, 197, 94, 0.4);
}
}
.online-count {
font-size: 14px;
color: $color-text-muted;
}
.connection-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #666;
&.connected {
background: #4ade80;
box-shadow: 0 0 10px rgba(74, 222, 128, 0.5);
}
}
}
}
.legend {
position: absolute;
bottom: 80px;
left: 40px;
display: flex;
gap: 24px;
background: rgba(0, 0, 0, 0.6);
padding: 12px 24px;
border-radius: 8px;
backdrop-filter: blur(10px);
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: $color-text-muted;
}
.legend-icon {
width: 16px;
height: 16px;
border-radius: 50%;
&.start {
background: $color-gold;
opacity: 0.5;
}
&.finish {
background: linear-gradient(135deg, $color-gold 50%, transparent 50%);
border: 2px solid $color-gold;
}
&.horse {
background: radial-gradient(circle, #fff 0%, $color-gold 50%, transparent 100%);
box-shadow: 0 0 10px $color-gold;
}
}
}
.footer-hint {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
color: $color-text-muted;
backdrop-filter: blur(10px);
}
</style>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useSocketClient } from '../composables/useSocketClient';
import { LotteryMachine, type Participant, type LotteryPhase } from '../pixi/LotteryMachine';
import type { AdminState } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
const router = useRouter();
const { isConnected, onlineUsers, lotteryState, onLotteryStateChange } = useSocketClient();
const { isConnected, onlineUsers, adminState, onAdminStateChange } = useSocketClient();
// Pixi canvas ref
const canvasRef = ref<HTMLCanvasElement | null>(null);
@@ -13,41 +15,67 @@ let lotteryMachine: LotteryMachine | null = null;
// Local state
const currentPhase = ref<LotteryPhase>('idle');
const currentPrize = ref({ level: '特等奖', name: 'iPhone 16 Pro Max' });
const currentPrize = ref({ level: '', name: '' });
const winners = ref<Participant[]>([]);
const currentRound = ref(1);
const isLoading = ref(true);
const participantCount = ref(0);
// Prize configuration
const prizes = [
{ level: '特等奖', name: 'iPhone 16 Pro Max', winnerCount: 1 },
{ level: '一等奖', name: 'iPad Pro', winnerCount: 3 },
{ level: '二等奖', name: 'AirPods Pro', winnerCount: 5 },
{ level: '三等奖', name: '京东卡 500元', winnerCount: 10 },
];
// Track previous lottery subPhase to detect changes
let previousSubPhase: string | null = null;
// Mock participants (would come from API)
const mockParticipants: Participant[] = [
{ id: '1', name: '张三', department: '技术部', zodiac: 'horse', age: 28 },
{ id: '2', name: '李四', department: '产品部', zodiac: 'dragon', age: 32 },
{ id: '3', name: '王五', department: '设计部', zodiac: 'horse', age: 26 },
{ id: '4', name: '赵六', department: '市场部', zodiac: 'tiger', age: 35 },
{ id: '5', name: '钱七', department: '运营部', zodiac: 'horse', age: 29 },
{ id: '6', name: '孙八', department: '人事部', zodiac: 'rabbit', age: 31 },
{ id: '7', name: '周九', department: '财务部', zodiac: 'snake', age: 27 },
{ id: '8', name: '吴十', department: '销售部', zodiac: 'horse', age: 33 },
{ id: '9', name: '郑十一', department: '技术部', zodiac: 'monkey', age: 25 },
{ id: '10', name: '王十二', department: '产品部', zodiac: 'horse', age: 30 },
{ id: '11', name: '冯十三', department: '设计部', zodiac: 'rooster', age: 28 },
{ id: '12', name: '陈十四', department: '市场部', zodiac: 'dog', age: 34 },
{ id: '13', name: '褚十五', department: '运营部', zodiac: 'horse', age: 26 },
{ id: '14', name: '卫十六', department: '人事部', zodiac: 'pig', age: 29 },
{ id: '15', name: '蒋十七', department: '财务部', zodiac: 'rat', age: 31 },
{ id: '16', name: '沈十八', department: '销售部', zodiac: 'ox', age: 27 },
{ id: '17', name: '韩十九', department: '技术部', zodiac: 'horse', age: 32 },
{ id: '18', name: '杨二十', department: '产品部', zodiac: 'tiger', age: 28 },
{ id: '19', name: '朱廿一', department: '设计部', zodiac: 'rabbit', age: 25 },
{ id: '20', name: '秦廿二', department: '市场部', zodiac: 'horse', age: 30 },
];
// Prize configuration - 从服务器加载
const prizes = ref<Array<{ level: string; name: string; winnerCount: number; poolTag?: string }>>([]);
// 从 API 获取奖项配置
async function fetchPrizes() {
try {
const response = await fetch('/api/admin/prizes');
const data = await response.json();
if (data.success && data.data?.prizes) {
prizes.value = data.data.prizes;
// 更新当前奖品显示
const currentConfig = data.data.prizes.find((p: any) => p.round === currentRound.value);
if (currentConfig) {
currentPrize.value = { level: currentConfig.level, name: currentConfig.name };
}
console.log('[抽奖] 已加载奖项配置:', prizes.value);
}
} catch (error) {
console.error('[抽奖] 获取奖项配置失败:', error);
}
}
// 从服务器加载的参与者列表
let realParticipants: Participant[] = [];
// 从 API 获取导入的参与者名单
async function fetchParticipants() {
try {
isLoading.value = true;
const response = await fetch('/api/admin/participants');
const data = await response.json();
if (data.success && data.data?.participants) {
// 将后端格式转换为前端 Participant 格式
realParticipants = data.data.participants.map((p: any) => ({
id: p.id,
name: p.name,
department: p.position, // 岗位作为部门显示
zodiac: p.tags?.includes('horse') ? 'horse' : 'other',
age: new Date().getFullYear() - p.birthYear,
}));
participantCount.value = realParticipants.length;
console.log(`[抽奖] 已加载 ${realParticipants.length} 名参与者`);
} else {
console.warn('[抽奖] 未找到参与者数据,请先导入名单');
}
} catch (error) {
console.error('[抽奖] 获取参与者失败:', error);
} finally {
isLoading.value = false;
}
}
// Unsubscribe function
let unsubscribeLottery: (() => void) | null = null;
@@ -64,12 +92,68 @@ function handleWinnersRevealed(revealedWinners: Participant[]) {
winners.value = revealedWinners;
}
// Handle admin state changes from control panel
function handleAdminStateChange(state: AdminState) {
const lottery = state.lottery;
const newSubPhase = lottery.subPhase;
// Update local state
currentRound.value = lottery.round;
currentPrize.value = {
level: lottery.prizeLevel,
name: lottery.prizeName,
};
// Detect phase transitions and trigger actions
if (newSubPhase !== previousSubPhase) {
console.log(`[BigScreen] Lottery phase: ${previousSubPhase} -> ${newSubPhase}`);
if (newSubPhase === 'GALAXY') {
// Clear winners when starting new round
winners.value = [];
startGalaxy();
} else if (newSubPhase === 'STORM') {
startRolling();
} else if (newSubPhase === 'REVEAL') {
// Use winners from server instead of local random
if (lottery.currentWinners && lottery.currentWinners.length > 0) {
const serverWinners = lottery.currentWinners.map(w => ({
id: w.id,
name: w.name,
department: w.department,
zodiac: 'unknown' as const,
age: 0,
}));
winners.value = serverWinners;
stopAndRevealWithWinners(serverWinners);
} else {
stopAndReveal();
}
} else if (newSubPhase === 'IDLE' && previousSubPhase === 'COMPLETE') {
resetLottery();
}
previousSubPhase = newSubPhase;
}
}
// Control functions
function startGalaxy() {
if (!lotteryMachine) return;
// Clear previous winners
winners.value = [];
// 使用真实参与者数据(如果有的话)
const participants = realParticipants.length > 0 ? realParticipants : [];
if (participants.length === 0) {
console.warn('[抽奖] 无参与者数据,请先导入名单');
return;
}
// Set participants and start galaxy view
lotteryMachine.setParticipants(mockParticipants);
lotteryMachine.setParticipants(participants);
// Apply filter for Round 4 (Horse zodiac)
if (currentRound.value === 4) {
@@ -87,9 +171,10 @@ function startRolling() {
function stopAndReveal() {
if (!lotteryMachine) return;
// Pick random winners
const prize = prizes[currentRound.value - 1];
const eligibleParticipants = mockParticipants.filter(p => {
// 使用真实参与者数据进行本地随机抽取(服务器未返回中奖者的降级方案)
const participants = realParticipants.length > 0 ? realParticipants : [];
const prize = prizes.value.find(p => p.round === currentRound.value) || prizes.value[0];
const eligibleParticipants = participants.filter(p => {
if (currentRound.value === 4) {
return p.zodiac === 'horse';
}
@@ -97,7 +182,24 @@ function stopAndReveal() {
});
const shuffled = [...eligibleParticipants].sort(() => Math.random() - 0.5);
const winnerIds = shuffled.slice(0, prize.winnerCount).map(p => p.id);
const winnerIds = shuffled.slice(0, prize?.winnerCount || 1).map(p => p.id);
lotteryMachine.revealWinners(winnerIds);
}
// Reveal winners using server-provided data
function stopAndRevealWithWinners(serverWinners: Participant[]) {
if (!lotteryMachine) return;
// Map server winners to participant IDs for the lottery machine
const winnerIds = serverWinners.map(w => w.id);
// Add server winners to participants if not already present
serverWinners.forEach(sw => {
if (!realParticipants.find(p => p.id === sw.id)) {
realParticipants.push(sw);
}
});
lotteryMachine.revealWinners(winnerIds);
}
@@ -106,10 +208,10 @@ function nextRound() {
if (!lotteryMachine) return;
currentRound.value = Math.min(4, currentRound.value + 1);
currentPrize.value = {
level: prizes[currentRound.value - 1].level,
name: prizes[currentRound.value - 1].name,
};
const nextPrize = prizes.value.find(p => p.round === currentRound.value);
if (nextPrize) {
currentPrize.value = { level: nextPrize.level, name: nextPrize.name };
}
winners.value = [];
lotteryMachine.reset();
@@ -119,7 +221,10 @@ function resetLottery() {
if (!lotteryMachine) return;
currentRound.value = 1;
currentPrize.value = { level: prizes[0].level, name: prizes[0].name };
const firstPrize = prizes.value.find(p => p.round === 1) || prizes.value[0];
if (firstPrize) {
currentPrize.value = { level: firstPrize.level, name: firstPrize.name };
}
winners.value = [];
lotteryMachine.reset();
@@ -128,6 +233,9 @@ function resetLottery() {
onMounted(async () => {
await nextTick();
// 首先从服务器加载配置和参与者数据
await Promise.all([fetchPrizes(), fetchParticipants()]);
// Initialize lottery machine
if (canvasRef.value) {
lotteryMachine = new LotteryMachine();
@@ -138,12 +246,14 @@ onMounted(async () => {
lotteryMachine.onWinners(handleWinnersRevealed);
}
// Subscribe to lottery state changes from server
unsubscribeLottery = onLotteryStateChange((state) => {
if (state.phase === 'rolling' && currentPhase.value !== 'storm') {
startRolling();
}
});
// Subscribe to admin state changes from control panel
unsubscribeLottery = onAdminStateChange(handleAdminStateChange);
// Request initial admin state
const socket = useSocketClient().getSocket();
if (socket) {
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
}
});
onUnmounted(() => {
@@ -208,48 +318,16 @@ onUnmounted(() => {
</div>
</div>
<!-- Control Panel -->
<div class="control-panel">
<button
v-if="currentPhase === 'idle'"
class="control-btn primary"
@click="startGalaxy"
>
开始展示
</button>
<button
v-if="currentPhase === 'galaxy'"
class="control-btn primary"
@click="startRolling"
>
开始抽奖
</button>
<button
v-if="currentPhase === 'storm'"
class="control-btn danger"
@click="stopAndReveal"
>
停止抽奖
</button>
<button
v-if="currentPhase === 'complete'"
class="control-btn secondary"
@click="nextRound"
:disabled="currentRound >= 4"
>
下一轮
</button>
<button
v-if="currentPhase === 'complete'"
class="control-btn outline"
@click="resetLottery"
>
重置
</button>
<!-- Status Panel (controlled by admin) -->
<div class="status-panel">
<span class="status-text">
{{ currentPhase === 'idle' ? '等待管理员开始...' :
currentPhase === 'galaxy' ? '参与者展示中' :
currentPhase === 'storm' ? '抽奖进行中...' :
currentPhase === 'reveal' ? '揭晓中...' :
'本轮抽奖完成' }}
</span>
<span class="admin-hint">由管理控制台控制</span>
</div>
</div>
</div>
@@ -413,10 +491,14 @@ onUnmounted(() => {
text-align: center;
pointer-events: none;
z-index: 100;
// Constrain to scroll area (600x400 with padding)
max-width: 560px;
max-height: 360px;
overflow-y: auto;
.winners-title {
font-size: 36px;
margin-bottom: 30px;
font-size: 28px;
margin-bottom: 20px;
text-shadow: $glow-gold;
}
@@ -424,88 +506,60 @@ onUnmounted(() => {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
max-width: 800px;
gap: 12px;
padding: 0 10px;
}
.winner-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 30px;
background: rgba(0, 0, 0, 0.7);
border: 2px solid $color-gold;
border-radius: 12px;
backdrop-filter: blur(10px);
padding: 12px 16px;
min-width: 80px;
background: transparent;
border: none;
border-radius: 8px;
.winner-name {
font-size: 28px;
font-size: 22px;
color: $color-gold;
font-weight: bold;
margin-bottom: 8px;
margin-bottom: 4px;
text-shadow: $glow-gold;
}
.winner-dept {
font-size: 16px;
color: $color-text-muted;
font-size: 12px;
color: $color-gold;
opacity: 0.8;
}
}
}
.control-panel {
.status-panel {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 40px;
background: rgba(0, 0, 0, 0.5);
border-radius: 12px;
backdrop-filter: blur(10px);
.control-btn {
padding: 16px 40px;
font-size: 20px;
.status-text {
font-size: 18px;
color: $color-gold;
font-weight: bold;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: scale(1.05);
}
&.primary {
background: linear-gradient(135deg, $color-gold-dark, $color-gold);
color: #000;
box-shadow: $glow-gold;
}
&.danger {
background: linear-gradient(135deg, $color-primary-dark, $color-primary);
color: $color-text-light;
box-shadow: $glow-red;
}
&.secondary {
background: rgba($color-gold, 0.2);
color: $color-gold;
border: 2px solid $color-gold;
}
&.outline {
background: transparent;
color: $color-text-muted;
border: 1px solid $color-text-muted;
&:hover:not(:disabled) {
border-color: $color-text-light;
color: $color-text-light;
}
}
.admin-hint {
font-size: 12px;
color: $color-text-muted;
opacity: 0.7;
}
}
</style>