feat: add Admin Control Panel, voting status check, and router security

Admin Control Panel:
- Add full AdminControl.vue with 3 sections (Voting, Lottery, Global)
- Add AdminLogin.vue with access code gate (20268888)
- Add admin.ts store with state persistence
- Add admin.types.ts with state machine types
- Add router guards for /admin/director-console

Voting System Fixes:
- Add voting status check before accepting votes (VOTING_CLOSED error)
- Fix client to display server error messages
- Fix button disabled logic to prevent ambiguity in paused state
- Auto-generate userId on connect to fix UNAUTHORIZED error

Big Screen Enhancements:
- Add LiveVotingView.vue with particle system
- Add LotteryMachine.ts with 3-stage animation (Galaxy/Storm/Reveal)
- Add useSocketClient.ts composable
- Fix MainDisplay.vue SCSS syntax error
- Add admin state sync listener in display store

Server Updates:
- Add admin.service.ts for state management
- Add isVotingOpen() and getVotingStatus() methods
- Add admin socket event handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-15 15:34:37 +08:00
parent e7397d22a9
commit 30cd29d45d
45 changed files with 7791 additions and 715 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken } from '../router';
const router = useRouter();
const route = useRoute();
const accessCode = ref('');
const error = ref('');
const isLoading = ref(false);
async function handleLogin() {
error.value = '';
if (!accessCode.value.trim()) {
error.value = '请输入访问码';
return;
}
isLoading.value = true;
// Simulate network delay for UX
await new Promise(resolve => setTimeout(resolve, 500));
if (accessCode.value === ADMIN_ACCESS_CODE) {
// Save token to localStorage
localStorage.setItem(ADMIN_TOKEN_KEY, generateToken(ADMIN_ACCESS_CODE));
// Redirect to console or original destination
const redirect = route.query.redirect as string || '/admin/director-console';
router.push(redirect);
} else {
error.value = '访问码错误';
accessCode.value = '';
}
isLoading.value = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleLogin();
}
}
</script>
<template>
<div class="admin-login">
<div class="login-box">
<div class="lock-icon">🔒</div>
<h1 class="title">导演控制台</h1>
<p class="subtitle">请输入访问码</p>
<div class="input-group">
<input
v-model="accessCode"
type="password"
placeholder="访问码"
autocomplete="off"
:disabled="isLoading"
@keydown="handleKeydown"
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button
class="login-btn"
:disabled="isLoading"
@click="handleLogin"
>
{{ isLoading ? '验证中...' : '进入控制台' }}
</button>
<p class="hint">仅限活动导演使用</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.admin-login {
width: 100%;
height: 100%;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
width: 360px;
padding: 40px;
text-align: center;
}
.lock-icon {
font-size: 48px;
margin-bottom: 20px;
opacity: 0.6;
}
.title {
font-size: 24px;
font-weight: 600;
color: #e0e0e0;
margin: 0 0 8px 0;
}
.subtitle {
font-size: 14px;
color: #666;
margin: 0 0 32px 0;
}
.input-group {
margin-bottom: 16px;
input {
width: 100%;
padding: 14px 16px;
font-size: 16px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
color: #e0e0e0;
text-align: center;
letter-spacing: 4px;
&:focus {
outline: none;
border-color: #3b82f6;
}
&:disabled {
opacity: 0.5;
}
&::placeholder {
letter-spacing: normal;
color: #555;
}
}
}
.error-message {
color: #ef4444;
font-size: 14px;
margin-bottom: 16px;
}
.login-btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 500;
background: #3b82f6;
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.hint {
font-size: 12px;
color: #444;
margin: 24px 0 0 0;
}
</style>

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useSocketClient, type VoteEvent } from '../composables/useSocketClient';
import { VoteParticleSystem, type ProgramTarget } from '../pixi/VoteParticleSystem';
const router = useRouter();
const { isConnected, onlineUsers, onVoteUpdate } = useSocketClient();
// Pixi canvas ref
const canvasRef = ref<HTMLCanvasElement | null>(null);
let particleSystem: VoteParticleSystem | null = null;
// Programs data (would come from API in production)
const programs = ref([
{ id: 'p1', name: '龙腾四海', team: '市场部', votes: 0 },
{ id: 'p2', name: '金马奔腾', team: '技术部', votes: 0 },
{ id: 'p3', name: '春风得意', team: '人力资源部', votes: 0 },
{ id: 'p4', name: '鸿运当头', team: '财务部', votes: 0 },
{ id: 'p5', name: '马到成功', team: '运营部', votes: 0 },
{ id: 'p6', name: '一马当先', team: '产品部', votes: 0 },
{ id: 'p7', name: '万马奔腾', team: '设计部', votes: 0 },
{ id: 'p8', name: '龙马精神', team: '销售部', votes: 0 },
]);
// Program card refs for position tracking
const programRefs = ref<Map<string, HTMLElement>>(new Map());
// Unsubscribe function
let unsubscribeVote: (() => void) | null = null;
function goBack() {
router.push('/');
}
function setProgramRef(id: string, el: HTMLElement | null) {
if (el) {
programRefs.value.set(id, el);
}
}
function updateTargetPositions() {
if (!particleSystem) return;
programRefs.value.forEach((el, id) => {
const rect = el.getBoundingClientRect();
const target: ProgramTarget = {
id,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
name: programs.value.find(p => p.id === id)?.name || '',
};
particleSystem!.registerTarget(target);
});
}
function handleVoteUpdate(event: VoteEvent) {
// Update vote count
const program = programs.value.find(p => p.id === event.candidateId);
if (program) {
program.votes = event.totalVotes;
}
// Spawn particle effect
if (particleSystem) {
particleSystem.spawnVoteParticle(event.candidateId);
}
}
// Demo: simulate votes for testing
function simulateVote() {
const randomProgram = programs.value[Math.floor(Math.random() * programs.value.length)];
randomProgram.votes++;
if (particleSystem) {
particleSystem.spawnVoteParticle(randomProgram.id);
}
}
onMounted(async () => {
await nextTick();
// Initialize particle system
if (canvasRef.value) {
particleSystem = new VoteParticleSystem();
await particleSystem.init(canvasRef.value);
// Register initial targets after DOM is ready
setTimeout(() => {
updateTargetPositions();
}, 100);
}
// Subscribe to vote updates
unsubscribeVote = onVoteUpdate(handleVoteUpdate);
// Handle window resize
window.addEventListener('resize', updateTargetPositions);
});
onUnmounted(() => {
if (particleSystem) {
particleSystem.destroy();
particleSystem = null;
}
if (unsubscribeVote) {
unsubscribeVote();
}
window.removeEventListener('resize', updateTargetPositions);
});
</script>
<template>
<div class="live-voting-view">
<!-- Pixi Canvas (full screen, behind content) -->
<canvas ref="canvasRef" class="particle-canvas"></canvas>
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title gold-text">实时投票</h1>
<div class="status">
<span class="online-count">{{ onlineUsers }} 人在线</span>
<span class="connection-dot" :class="{ connected: isConnected }"></span>
</div>
</header>
<!-- Programs Grid -->
<main class="programs-grid">
<div
v-for="program in programs"
:key="program.id"
:ref="(el) => setProgramRef(program.id, el as HTMLElement)"
class="program-card"
:class="{ 'has-votes': program.votes > 0 }"
>
<div class="card-glow"></div>
<div class="card-content">
<h2 class="program-name">{{ program.name }}</h2>
<p class="team-name">{{ program.team }}</p>
<div class="vote-indicator">
<div class="heat-bar">
<div
class="heat-fill"
:style="{ width: Math.min(100, program.votes * 5) + '%' }"
></div>
</div>
</div>
</div>
</div>
</main>
<!-- Demo Controls (remove in production) -->
<div class="demo-controls">
<button class="demo-btn" @click="simulateVote">
模拟投票 (测试)
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.live-voting-view {
width: 100%;
height: 100%;
background: $color-bg-gradient;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.particle-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30px 50px;
position: relative;
z-index: 20;
.back-btn {
background: none;
border: 1px solid $color-gold;
color: $color-gold;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all $transition-fast;
&:hover {
background: rgba($color-gold, 0.1);
}
}
.title {
font-size: 48px;
font-weight: bold;
}
.status {
display: flex;
align-items: center;
gap: 12px;
.online-count {
color: $color-text-muted;
font-size: 18px;
}
.connection-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #666;
transition: background 0.3s;
&.connected {
background: #4ade80;
box-shadow: 0 0 10px rgba(74, 222, 128, 0.5);
}
}
}
}
.programs-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30px;
padding: 40px 50px;
position: relative;
z-index: 5;
}
.program-card {
position: relative;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba($color-gold, 0.2);
border-radius: 16px;
padding: 30px;
transition: all 0.3s ease;
&:hover {
border-color: rgba($color-gold, 0.4);
transform: translateY(-4px);
}
&.has-votes {
border-color: rgba($color-gold, 0.5);
.card-glow {
opacity: 0.3;
}
}
.card-glow {
position: absolute;
inset: -20px;
background: radial-gradient(
circle at center,
rgba($color-gold, 0.3) 0%,
transparent 70%
);
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
z-index: -1;
}
.card-content {
position: relative;
z-index: 1;
}
.program-name {
font-size: 28px;
font-weight: bold;
color: $color-text-light;
margin-bottom: 8px;
font-family: 'Noto Serif SC', serif;
}
.team-name {
font-size: 16px;
color: $color-text-muted;
margin-bottom: 20px;
}
.vote-indicator {
.heat-bar {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
.heat-fill {
height: 100%;
background: linear-gradient(90deg, $color-gold-dark, $color-gold);
border-radius: 4px;
transition: width 0.5s ease;
box-shadow: 0 0 10px rgba($color-gold, 0.5);
}
}
}
}
.demo-controls {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 100;
.demo-btn {
padding: 12px 24px;
font-size: 16px;
color: $color-text-light;
background: rgba($color-primary, 0.8);
border: 1px solid $color-primary-light;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: $color-primary;
transform: scale(1.05);
}
}
}
</style>

View File

@@ -1,147 +1,287 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useDisplayStore } from '../stores/display';
import { useSocketClient } from '../composables/useSocketClient';
import { LotteryMachine, type Participant, type LotteryPhase } from '../pixi/LotteryMachine';
const router = useRouter();
const displayStore = useDisplayStore();
const { isConnected, onlineUsers, lotteryState, onLotteryStateChange } = useSocketClient();
// Draw state
const isSpinning = ref(false);
const spinningNames = ref<string[]>([]);
const winner = ref<{ name: string; department: string } | null>(null);
// Pixi canvas ref
const canvasRef = ref<HTMLCanvasElement | null>(null);
let lotteryMachine: LotteryMachine | null = null;
// Mock participants for demo
const mockParticipants = [
{ name: '张三', department: '技术部' },
{ name: '李四', department: '产品部' },
{ name: '王五', department: '设计部' },
{ name: '赵六', department: '市场部' },
{ name: '钱七', department: '运营部' },
{ name: '孙八', department: '人事部' },
{ name: '周九', department: '财务部' },
{ name: '吴十', department: '销售部' },
// Local state
const currentPhase = ref<LotteryPhase>('idle');
const currentPrize = ref({ level: '特等奖', name: 'iPhone 16 Pro Max' });
const winners = ref<Participant[]>([]);
const currentRound = ref(1);
// 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 },
];
let spinInterval: ReturnType<typeof setInterval> | 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 },
];
function startDraw() {
if (isSpinning.value) return;
isSpinning.value = true;
winner.value = null;
// Start spinning animation
let speed = 50;
let index = 0;
spinInterval = setInterval(() => {
spinningNames.value = [
mockParticipants[(index) % mockParticipants.length].name,
mockParticipants[(index + 1) % mockParticipants.length].name,
mockParticipants[(index + 2) % mockParticipants.length].name,
];
index++;
}, speed);
// Slow down and stop after 5 seconds
setTimeout(() => {
slowDownAndStop();
}, 5000);
}
function slowDownAndStop() {
if (spinInterval) {
clearInterval(spinInterval);
}
// Pick random winner
const winnerIndex = Math.floor(Math.random() * mockParticipants.length);
winner.value = mockParticipants[winnerIndex];
isSpinning.value = false;
spinningNames.value = [];
}
// Unsubscribe function
let unsubscribeLottery: (() => void) | null = null;
function goBack() {
router.push('/');
}
function handlePhaseChange(phase: LotteryPhase) {
currentPhase.value = phase;
}
function handleWinnersRevealed(revealedWinners: Participant[]) {
winners.value = revealedWinners;
}
// Control functions
function startGalaxy() {
if (!lotteryMachine) return;
// Set participants and start galaxy view
lotteryMachine.setParticipants(mockParticipants);
// Apply filter for Round 4 (Horse zodiac)
if (currentRound.value === 4) {
lotteryMachine.filterParticipants({ zodiac: 'horse' });
}
lotteryMachine.startGalaxy();
}
function startRolling() {
if (!lotteryMachine) return;
lotteryMachine.startStorm();
}
function stopAndReveal() {
if (!lotteryMachine) return;
// Pick random winners
const prize = prizes[currentRound.value - 1];
const eligibleParticipants = mockParticipants.filter(p => {
if (currentRound.value === 4) {
return p.zodiac === 'horse';
}
return true;
});
const shuffled = [...eligibleParticipants].sort(() => Math.random() - 0.5);
const winnerIds = shuffled.slice(0, prize.winnerCount).map(p => p.id);
lotteryMachine.revealWinners(winnerIds);
}
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,
};
winners.value = [];
lotteryMachine.reset();
}
function resetLottery() {
if (!lotteryMachine) return;
currentRound.value = 1;
currentPrize.value = { level: prizes[0].level, name: prizes[0].name };
winners.value = [];
lotteryMachine.reset();
}
onMounted(async () => {
await nextTick();
// Initialize lottery machine
if (canvasRef.value) {
lotteryMachine = new LotteryMachine();
await lotteryMachine.init(canvasRef.value);
// Set callbacks
lotteryMachine.onPhase(handlePhaseChange);
lotteryMachine.onWinners(handleWinnersRevealed);
}
// Subscribe to lottery state changes from server
unsubscribeLottery = onLotteryStateChange((state) => {
if (state.phase === 'rolling' && currentPhase.value !== 'storm') {
startRolling();
}
});
});
onUnmounted(() => {
if (spinInterval) {
clearInterval(spinInterval);
if (lotteryMachine) {
lotteryMachine.destroy();
lotteryMachine = null;
}
if (unsubscribeLottery) {
unsubscribeLottery();
}
});
</script>
<template>
<div class="lucky-draw-view">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title gold-text">幸运抽奖</h1>
<div class="online">{{ displayStore.onlineUsers }} 人在线</div>
</header>
<!-- Pixi Canvas (full screen) -->
<canvas ref="canvasRef" class="lottery-canvas"></canvas>
<!-- Main draw area -->
<main class="draw-area">
<!-- Prize display -->
<div class="prize-section">
<!-- Overlay UI -->
<div class="overlay-ui">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<div class="round-indicator">
<span class="round-label"> {{ currentRound }} </span>
<span v-if="currentRound === 4" class="filter-badge">🐴 马年限定</span>
</div>
<div class="status">
<span class="online-count">{{ onlineUsers }} 人在线</span>
<span class="connection-dot" :class="{ connected: isConnected }"></span>
</div>
</header>
<!-- Prize Display -->
<div class="prize-display" :class="{ hidden: currentPhase === 'storm' }">
<div class="prize-badge">
<span class="level">特等奖</span>
<span class="name">iPhone 16 Pro Max</span>
<span class="prize-level">{{ currentPrize.level }}</span>
<span class="prize-name">{{ currentPrize.name }}</span>
</div>
</div>
<!-- Spinning names -->
<div class="spin-section">
<div v-if="isSpinning" class="spinning-names">
<div v-for="(name, i) in spinningNames" :key="i" class="name-item">
{{ name }}
</div>
</div>
<!-- Phase Indicator -->
<div class="phase-indicator">
<span class="phase-text">
{{ currentPhase === 'idle' ? '准备就绪' :
currentPhase === 'galaxy' ? '参与者展示' :
currentPhase === 'storm' ? '抽奖进行中...' :
currentPhase === 'reveal' ? '揭晓中...' :
'恭喜中奖!' }}
</span>
</div>
<!-- Winner display -->
<div v-else-if="winner" class="winner-display">
<div class="winner-card">
<div class="avatar">🎉</div>
<div class="info">
<h2 class="name gold-text">{{ winner.name }}</h2>
<p class="department">{{ winner.department }}</p>
</div>
<!-- Winners Display (shown after reveal) -->
<div v-if="currentPhase === 'complete' && winners.length > 0" class="winners-overlay">
<h2 class="winners-title gold-text">🎉 恭喜以下获奖者 🎉</h2>
<div class="winners-list">
<div v-for="winner in winners" :key="winner.id" class="winner-item">
<span class="winner-name">{{ winner.name }}</span>
<span class="winner-dept">{{ winner.department }}</span>
</div>
<p class="congrats">恭喜中奖</p>
</div>
<!-- Idle state -->
<div v-else class="idle-state">
<p>点击下方按钮开始抽奖</p>
</div>
</div>
<!-- Draw button -->
<div class="action-section">
<!-- Control Panel -->
<div class="control-panel">
<button
class="draw-btn"
:class="{ spinning: isSpinning }"
:disabled="isSpinning"
@click="startDraw"
v-if="currentPhase === 'idle'"
class="control-btn primary"
@click="startGalaxy"
>
{{ isSpinning ? '抽奖中...' : '开始抽奖' }}
开始展示
</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>
</div>
</main>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
@use '../assets/styles/variables.scss' as *;
.lucky-draw-view {
width: 100%;
height: 100%;
background: $color-bg-gradient;
position: relative;
overflow: hidden;
}
.lottery-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 {
@@ -149,9 +289,10 @@ onUnmounted(() => {
justify-content: space-between;
align-items: center;
padding: 30px 50px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.5) 0%, transparent 100%);
.back-btn {
background: none;
background: rgba(0, 0, 0, 0.3);
border: 1px solid $color-gold;
color: $color-gold;
padding: 10px 20px;
@@ -159,51 +300,88 @@ onUnmounted(() => {
cursor: pointer;
font-size: 16px;
transition: all $transition-fast;
backdrop-filter: blur(10px);
&:hover {
background: rgba($color-gold, 0.1);
background: rgba($color-gold, 0.2);
}
}
.title {
font-size: 48px;
font-weight: bold;
.round-indicator {
display: flex;
align-items: center;
gap: 12px;
.round-label {
font-size: 24px;
color: $color-gold;
font-weight: bold;
}
.filter-badge {
padding: 6px 12px;
background: rgba($color-gold, 0.2);
border: 1px solid $color-gold;
border-radius: 20px;
font-size: 14px;
color: $color-gold;
}
}
.online {
color: $color-text-muted;
font-size: 18px;
.status {
display: flex;
align-items: center;
gap: 12px;
.online-count {
color: $color-text-muted;
font-size: 18px;
}
.connection-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #666;
transition: background 0.3s;
&.connected {
background: #4ade80;
box-shadow: 0 0 10px rgba(74, 222, 128, 0.5);
}
}
}
}
.draw-area {
flex: 1;
.prize-display {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 50px;
}
padding: 20px;
transition: opacity 0.5s, transform 0.5s;
.prize-section {
margin-bottom: 60px;
&.hidden {
opacity: 0;
transform: translateY(-20px);
}
.prize-badge {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 60px;
padding: 20px 60px;
background: rgba(0, 0, 0, 0.5);
border: 2px solid $color-gold;
border-radius: 16px;
background: rgba($color-gold, 0.1);
backdrop-filter: blur(10px);
.level {
.prize-level {
font-size: 24px;
color: $color-gold;
margin-bottom: 10px;
margin-bottom: 8px;
text-shadow: $glow-gold;
}
.name {
.prize-name {
font-size: 36px;
color: $color-text-light;
font-weight: bold;
@@ -211,116 +389,123 @@ onUnmounted(() => {
}
}
.spin-section {
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-bottom: 60px;
.phase-indicator {
position: absolute;
bottom: 150px;
left: 50%;
transform: translateX(-50%);
.spinning-names {
display: flex;
flex-direction: column;
gap: 20px;
.name-item {
font-size: 72px;
color: $color-text-light;
text-align: center;
animation: flash 0.1s infinite;
&:nth-child(2) {
font-size: 96px;
color: $color-gold;
text-shadow: $glow-gold;
}
}
}
.winner-display {
text-align: center;
.winner-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 40px 80px;
border: 3px solid $color-gold;
border-radius: 20px;
background: rgba($color-gold, 0.1);
animation: scale-pulse 1s ease-in-out infinite;
.avatar {
font-size: 80px;
}
.name {
font-size: 64px;
font-weight: bold;
}
.department {
font-size: 24px;
color: $color-text-muted;
}
}
.congrats {
margin-top: 30px;
font-size: 36px;
color: $color-primary-light;
text-shadow: $glow-red;
}
}
.idle-state {
font-size: 24px;
.phase-text {
font-size: 20px;
color: $color-text-muted;
padding: 10px 30px;
background: rgba(0, 0, 0, 0.5);
border-radius: 30px;
backdrop-filter: blur(10px);
}
}
.action-section {
.draw-btn {
padding: 20px 80px;
font-size: 32px;
.winners-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
z-index: 100;
.winners-title {
font-size: 36px;
margin-bottom: 30px;
text-shadow: $glow-gold;
}
.winners-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
max-width: 800px;
}
.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);
.winner-name {
font-size: 28px;
color: $color-gold;
font-weight: bold;
margin-bottom: 8px;
}
.winner-dept {
font-size: 16px;
color: $color-text-muted;
}
}
}
.control-panel {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
.control-btn {
padding: 16px 40px;
font-size: 20px;
font-weight: bold;
color: $color-text-light;
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
border: none;
border-radius: 50px;
border-radius: 12px;
cursor: pointer;
transition: all $transition-normal;
box-shadow: $glow-red;
transition: all 0.2s;
border: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: scale(1.05);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
&.primary {
background: linear-gradient(135deg, $color-gold-dark, $color-gold);
color: #000;
box-shadow: $glow-gold;
}
&.spinning {
animation: glow-pulse 0.5s infinite;
&.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;
}
}
}
}
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes scale-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: $glow-red; }
50% { box-shadow: 0 0 40px rgba($color-primary, 0.8), 0 0 80px rgba($color-primary, 0.5); }
}
</style>

View File

@@ -1,248 +1,294 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted, onUnmounted } from 'vue';
import { useDisplayStore } from '../stores/display';
import { BackgroundEffect } from '../pixi/BackgroundEffect';
const router = useRouter();
const displayStore = useDisplayStore();
const currentTime = ref(new Date().toLocaleTimeString('zh-CN'));
// Pixi.js background
const canvasRef = ref<HTMLCanvasElement | null>(null);
let backgroundEffect: BackgroundEffect | null = null;
// Update time every second
onMounted(() => {
setInterval(() => {
// Time display
const currentTime = ref(new Date().toLocaleTimeString('zh-CN'));
let timeInterval: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
// Connect to socket
displayStore.connect();
// Initialize Pixi.js background
if (canvasRef.value) {
backgroundEffect = new BackgroundEffect();
await backgroundEffect.init(canvasRef.value);
}
// Update time every second
timeInterval = setInterval(() => {
currentTime.value = new Date().toLocaleTimeString('zh-CN');
}, 1000);
});
function goToDraw() {
router.push('/draw');
}
function goToResults() {
router.push('/results');
}
onUnmounted(() => {
if (backgroundEffect) {
backgroundEffect.destroy();
}
if (timeInterval) {
clearInterval(timeInterval);
}
});
</script>
<template>
<div class="main-display">
<!-- Background particles will be added via Pixi.js -->
<div class="background-overlay"></div>
<div class="cinematic-display">
<!-- Pixi.js Canvas Background -->
<canvas ref="canvasRef" class="background-canvas"></canvas>
<!-- Header -->
<header class="header">
<div class="logo">
<span class="year gold-text">2026</span>
<span class="title">年会盛典</span>
</div>
<div class="status">
<span class="online-count">
<span class="dot"></span>
{{ displayStore.onlineUsers }} 人在线
</span>
<span class="time">{{ currentTime }}</span>
</div>
</header>
<!-- Corner Decorations -->
<div class="corner-decoration top-left"></div>
<div class="corner-decoration top-right"></div>
<div class="corner-decoration bottom-left"></div>
<div class="corner-decoration bottom-right"></div>
<!-- Main content -->
<!-- Main Content -->
<main class="content">
<div class="welcome-section">
<h1 class="main-title">
<span class="gold-text">马到成功</span>
</h1>
<p class="subtitle">2026 年度盛典</p>
<!-- Central Title -->
<div class="title-section">
<h1 class="main-title liquid-gold">马到成功</h1>
<div class="subtitle-wrapper">
<span class="year">2026</span>
<span class="divider">·</span>
<span class="event">年度盛典</span>
</div>
</div>
<!-- Action buttons -->
<div class="actions">
<button class="action-btn draw-btn" @click="goToDraw">
<span class="icon">🎁</span>
<span class="text">幸运抽奖</span>
</button>
<button class="action-btn results-btn" @click="goToResults">
<span class="icon">📊</span>
<span class="text">投票结果</span>
</button>
</div>
<!-- Particle Ring Placeholder -->
<div class="particle-ring"></div>
</main>
<!-- Decorative elements -->
<div class="decoration left-lantern"></div>
<div class="decoration right-lantern"></div>
<!-- Status Footer -->
<footer class="status-footer">
<div class="status-item">
<span class="dot online"></span>
<span class="label">{{ displayStore.onlineUsers }} 人在线</span>
</div>
<div class="status-item time">
<span class="label">{{ currentTime }}</span>
</div>
<div class="status-item">
<span class="dot" :class="displayStore.isConnected ? 'ready' : 'offline'"></span>
<span class="label">{{ displayStore.isConnected ? '已连接' : '未连接' }}</span>
</div>
</footer>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
@use '../assets/styles/variables.scss' as *;
.main-display {
.cinematic-display {
width: 100%;
height: 100%;
background: $color-bg-gradient;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: $color-bg-wine;
}
.background-overlay {
.background-canvas {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 0%, rgba(0, 0, 0, 0.5) 100%);
pointer-events: none;
width: 100%;
height: 100%;
z-index: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30px 50px;
position: relative;
// Corner decorations (Chinese knot pattern)
.corner-decoration {
position: absolute;
width: 120px;
height: 120px;
z-index: 10;
opacity: 0.6;
pointer-events: none;
.logo {
display: flex;
align-items: baseline;
gap: 16px;
.year {
font-size: 48px;
font-weight: bold;
}
.title {
font-size: 24px;
color: $color-text-light;
letter-spacing: 4px;
}
&::before,
&::after {
content: '';
position: absolute;
background: $color-gold;
}
.status {
display: flex;
align-items: center;
gap: 30px;
font-size: 18px;
color: $color-text-muted;
&::before {
width: 80px;
height: 2px;
}
.online-count {
display: flex;
align-items: center;
gap: 8px;
&::after {
width: 2px;
height: 80px;
}
.dot {
width: 10px;
height: 10px;
background: #52c41a;
border-radius: 50%;
animation: pulse 2s infinite;
}
}
&.top-left {
top: 40px;
left: 40px;
&::before { top: 0; left: 0; }
&::after { top: 0; left: 0; }
}
&.top-right {
top: 40px;
right: 40px;
&::before { top: 0; right: 0; }
&::after { top: 0; right: 0; }
}
&.bottom-left {
bottom: 40px;
left: 40px;
&::before { bottom: 0; left: 0; }
&::after { bottom: 0; left: 0; }
}
&.bottom-right {
bottom: 40px;
right: 40px;
&::before { bottom: 0; right: 0; }
&::after { bottom: 0; right: 0; }
}
}
.content {
flex: 1;
position: relative;
z-index: 5;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
z-index: 10;
}
.welcome-section {
.title-section {
text-align: center;
margin-bottom: 80px;
.main-title {
font-size: 120px;
font-weight: bold;
margin-bottom: 20px;
text-shadow: $glow-gold;
font-size: 160px;
font-weight: 900;
letter-spacing: 20px;
margin-bottom: 30px;
}
.subtitle {
font-size: 36px;
.subtitle-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
font-size: 32px;
color: $color-text-muted;
letter-spacing: 8px;
}
}
.actions {
display: flex;
gap: 60px;
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 40px 60px;
border: 2px solid $color-gold;
border-radius: 20px;
background: rgba($color-gold, 0.1);
cursor: pointer;
transition: all $transition-normal;
&:hover {
background: rgba($color-gold, 0.2);
transform: translateY(-5px);
box-shadow: $glow-gold;
}
.icon {
font-size: 64px;
}
.text {
font-size: 24px;
.year {
color: $color-gold;
letter-spacing: 4px;
}
}
.draw-btn {
border-color: $color-primary;
background: rgba($color-primary, 0.1);
&:hover {
background: rgba($color-primary, 0.2);
box-shadow: $glow-red;
font-weight: bold;
}
.text {
color: $color-primary-light;
.divider {
color: $color-gold-dark;
}
.event {
color: $color-text-light;
}
}
}
.decoration {
.particle-ring {
position: absolute;
width: 80px;
height: 120px;
background: linear-gradient(180deg, $color-primary 0%, $color-primary-dark 100%);
border-radius: 50% 50% 45% 45%;
opacity: 0.6;
width: 600px;
height: 600px;
border: 1px solid rgba($color-gold, 0.1);
border-radius: 50%;
animation: rotate-slow 60s linear infinite;
pointer-events: none;
&.left-lantern {
top: 100px;
left: 50px;
animation: float 4s ease-in-out infinite;
&::before {
content: '';
position: absolute;
inset: 40px;
border: 1px solid rgba($color-gold, 0.15);
border-radius: 50%;
animation: rotate-slow 45s linear infinite reverse;
}
&.right-lantern {
top: 100px;
right: 50px;
animation: float 4s ease-in-out infinite 1s;
&::after {
content: '';
position: absolute;
inset: 80px;
border: 1px solid rgba($color-gold, 0.08);
border-radius: 50%;
animation: rotate-slow 30s linear infinite;
}
}
.status-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 60px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
}
.status-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
color: $color-text-muted;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.online {
background: #52c41a;
box-shadow: 0 0 10px rgba(82, 196, 26, 0.5);
animation: pulse 2s infinite;
}
&.ready {
background: $color-gold;
box-shadow: 0 0 10px rgba($color-gold, 0.5);
}
&.offline {
background: #666;
}
}
&.time {
font-size: 18px;
color: $color-text-light;
font-variant-numeric: tabular-nums;
}
}
@keyframes rotate-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(-3deg); }
50% { transform: translateY(-15px) rotate(3deg); }
}
</style>

View File

@@ -62,7 +62,7 @@ const categories = [
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
@use '../assets/styles/variables.scss' as *;
.vote-results-view {
width: 100%;