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:
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@ const TICKET_TYPE_NAMES: Record<string, string> = {
|
||||
potential: '最具潜力',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
teamName: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
382
packages/client-screen/src/pixi/HorseRaceRenderer.ts
Normal file
382
packages/client-screen/src/pixi/HorseRaceRenderer.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
315
packages/client-screen/src/views/HorseRaceView.vue
Normal file
315
packages/client-screen/src/views/HorseRaceView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user