Files
company-celebration/packages/client-screen/src/views/AdminControl.vue
empty 84be8c4b5c feat: redesign Big Screen voting view with philatelic postcard UI
- Add PostcardItem.vue component with Chinese postal aesthetics
- Add PostcardGrid.vue container with 4x2 CSS Grid layout
- Add Postmark.vue component for real-time vote stamp visualization
- Update LiveVotingView.vue with cream paper theme (#FDFBF7)
- Add Year of the Horse 2026 stamp image
- Add responsive breakpoints for different screen sizes
- Enhance admin service with program voting control
- Add vote stamp accumulation for big screen display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:15:17 +08:00

1067 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminStore } from '../stores/admin';
import { PRIZE_CONFIG } from '@gala/shared/types';
const router = useRouter();
const admin = useAdminStore();
// Local UI state
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;
// Navigation
function goBack() {
router.push('/');
}
// Phase control
function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS') {
admin.setPhase(phase);
}
// Voting control
function openVoting() {
admin.controlVoting('open');
}
function closeVoting() {
admin.controlVoting('close');
}
function pauseVoting() {
admin.controlVoting('pause');
}
function resumeVoting() {
admin.controlVoting('resume');
}
// Program voting control
function nextProgram() {
const socket = admin.getSocket();
if (socket) {
socket.emit('admin:next_program' as any, {}, (response: any) => {
if (!response.success) {
console.error('Failed to move to next program:', response.message);
}
});
}
}
function startProgramVoting(programId: string) {
const socket = admin.getSocket();
if (socket) {
socket.emit('admin:start_program' as any, { programId }, (response: any) => {
if (!response.success) {
console.error('Failed to start program voting:', response.message);
}
});
}
}
function toggleLateCatch() {
const socket = admin.getSocket();
if (socket) {
const newValue = !admin.allowLateCatch;
socket.emit('admin:toggle_late_catch' as any, { enabled: newValue }, (response: any) => {
if (!response.success) {
console.error('Failed to toggle late catch:', response.message);
}
});
}
}
// Lottery control
function startGalaxy() {
admin.controlLottery('start_galaxy');
}
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() {
if (!admin.canStopLottery) return;
admin.controlLottery('stop_reveal');
}
function nextRound() {
admin.controlLottery('next_round');
}
function resetLottery() {
admin.controlLottery('reset');
}
// Emergency reset
function openResetModal(scope: 'all' | 'voting' | 'lottery') {
resetScope.value = scope;
confirmResetCode.value = '';
showResetModal.value = true;
}
function confirmReset() {
admin.emergencyReset(resetScope.value, confirmResetCode.value);
showResetModal.value = false;
confirmResetCode.value = '';
}
function cancelReset() {
showResetModal.value = false;
confirmResetCode.value = '';
}
// Music control
function toggleMusic() {
if (admin.musicPlaying) {
admin.controlMusic('stop');
} else {
admin.controlMusic('play', 'bgm');
}
}
function playFanfare() {
admin.controlMusic('play', 'fanfare');
}
// Computed helpers
const currentPrizeConfig = computed(() => {
return PRIZE_CONFIG.find(p => p.round === admin.lotteryRound) || PRIZE_CONFIG[0];
});
const isLastRound = computed(() => admin.lotteryRound >= 4);
const phaseLabel = computed(() => {
switch (admin.systemPhase) {
case 'IDLE': return '待机';
case 'VOTING': return '投票中';
case 'LOTTERY': return '抽奖中';
case 'RESULTS': return '结果展示';
default: return '未知';
}
});
const lotteryPhaseLabel = computed(() => {
switch (admin.lotterySubPhase) {
case 'IDLE': return '准备就绪';
case 'GALAXY': return '展示参与者';
case 'STORM': return '滚动中...';
case 'REVEAL': return '揭晓中';
case 'COMPLETE': return '已完成';
default: return '未知';
}
});
// Lifecycle
onMounted(() => {
admin.connect();
});
onUnmounted(() => {
if (countdownInterval) {
clearInterval(countdownInterval);
}
});
</script>
<template>
<div class="admin-control">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title">管理控制台</h1>
<div class="status-bar">
<span class="status-item">
<span class="dot" :class="admin.isConnected ? 'online' : 'offline'"></span>
{{ admin.isConnected ? '已连接' : '未连接' }}
</span>
<span class="status-item">
<span class="icon">👥</span>
{{ admin.onlineUsers }} 人在线
</span>
<span class="status-item phase-badge" :class="admin.systemPhase.toLowerCase()">
{{ phaseLabel }}
</span>
</div>
</header>
<!-- Error Toast -->
<div v-if="admin.lastError" class="error-toast" @click="admin.clearError">
{{ admin.lastError }}
<span class="close">×</span>
</div>
<!-- Main Content -->
<main class="content">
<!-- Section A: Voting Control -->
<section class="control-section">
<div class="section-header">
<h2>投票控制</h2>
<span class="section-status" :class="{ active: admin.votingOpen }">
{{ admin.votingOpen ? (admin.votingPaused ? '已暂停' : '进行中') : '已关闭' }}
</span>
</div>
<div class="section-body">
<div class="stat-row">
<div class="stat-item">
<span class="stat-label">总投票数</span>
<span class="stat-value">{{ admin.totalVotes }}</span>
</div>
</div>
<div class="button-group">
<button
class="ctrl-btn primary"
:disabled="admin.votingOpen || admin.votingPaused || admin.pendingAction === 'voting_open'"
@click="openVoting"
>
开启投票
</button>
<button
class="ctrl-btn warning"
:disabled="!admin.votingOpen || admin.pendingAction === 'voting_pause'"
@click="pauseVoting"
>
暂停
</button>
<button
class="ctrl-btn success"
:disabled="!admin.votingPaused || admin.pendingAction === 'voting_resume'"
@click="resumeVoting"
>
恢复
</button>
<button
class="ctrl-btn danger"
:disabled="(!admin.votingOpen && !admin.votingPaused) || admin.pendingAction === 'voting_close'"
@click="closeVoting"
>
关闭投票
</button>
</div>
</div>
</section>
<!-- Section A2: Program Votes Display -->
<section class="control-section program-section">
<div class="section-header">
<h2>节目票数</h2>
<span class="section-status"> {{ admin.programs.length }} 个节目</span>
</div>
<div class="section-body">
<!-- Program Vote List (Read-only) -->
<div class="program-list">
<div
v-for="(program, idx) in admin.programs"
:key="program.id"
class="program-item readonly"
>
<span class="program-order">{{ idx + 1 }}</span>
<span class="program-name">{{ program.name }}</span>
<span class="program-team">{{ program.teamName }}</span>
<span class="program-votes">{{ program.votes }} </span>
</div>
</div>
</div>
</section>
<!-- Section B: Lottery Controller -->
<section class="control-section lottery-section">
<div class="section-header">
<h2>抽奖控制</h2>
<div class="round-indicator">
<span class="round-badge"> {{ admin.lotteryRound }} </span>
<span class="phase-badge">{{ lotteryPhaseLabel }}</span>
</div>
</div>
<div class="section-body">
<!-- Prize Info -->
<div class="prize-info">
<div class="prize-level">{{ currentPrizeConfig.level }}</div>
<div class="prize-name">{{ currentPrizeConfig.name }}</div>
<div class="prize-count">× {{ currentPrizeConfig.winnerCount }} </div>
<div v-if="currentPrizeConfig.zodiacFilter" class="prize-filter">
限定: 属马
</div>
</div>
<!-- Lottery State Machine Controls -->
<div class="lottery-controls">
<!-- IDLE State -->
<button
v-if="admin.lotterySubPhase === 'IDLE'"
class="ctrl-btn primary large"
:disabled="admin.pendingAction === 'lottery_start_galaxy'"
@click="startGalaxy"
>
开始展示
</button>
<!-- GALAXY State -->
<button
v-if="admin.lotterySubPhase === 'GALAXY'"
class="ctrl-btn warning large"
:disabled="admin.pendingAction === 'lottery_start_storm'"
@click="startStorm"
>
开始滚动
</button>
<!-- STORM State -->
<button
v-if="admin.lotterySubPhase === 'STORM'"
class="ctrl-btn danger large"
:disabled="!admin.canStopLottery"
@click="stopAndReveal"
>
<span v-if="!admin.canStopLottery" class="countdown">
{{ countdownDisplay }}s
</span>
<span v-else>停止抽奖</span>
</button>
<!-- COMPLETE State -->
<div v-if="admin.lotterySubPhase === 'COMPLETE'" 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>
</div>
<!-- Winners Display -->
<div v-if="admin.currentWinners.length > 0" class="winners-display">
<h4>本轮中奖者</h4>
<div class="winners-list">
<div v-for="winner in admin.currentWinners" :key="winner.id" class="winner-item">
<span class="winner-name">{{ winner.name }}</span>
<span class="winner-dept">{{ winner.department }}</span>
</div>
</div>
</div>
<!-- Round Progress -->
<div class="round-progress">
<div
v-for="round in 4"
:key="round"
class="round-dot"
:class="{
active: round === admin.lotteryRound,
completed: round < admin.lotteryRound
}"
>
{{ round }}
</div>
</div>
</div>
</section>
<!-- Section C: Global Controls -->
<section class="control-section global-section">
<div class="section-header">
<h2>全局控制</h2>
</div>
<div class="section-body">
<!-- Display Mode -->
<div class="control-group">
<h4>显示模式</h4>
<div class="button-group">
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'IDLE' }"
@click="setPhase('IDLE')"
>
待机画面
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'VOTING' }"
@click="setPhase('VOTING')"
>
投票画面
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'LOTTERY' }"
@click="setPhase('LOTTERY')"
>
抽奖画面
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'RESULTS' }"
@click="setPhase('RESULTS')"
>
结果展示
</button>
</div>
</div>
<!-- Music Control -->
<div class="control-group">
<h4>音乐控制</h4>
<div class="button-group">
<button
class="ctrl-btn"
:class="{ active: admin.musicPlaying }"
@click="toggleMusic"
>
{{ admin.musicPlaying ? '停止音乐' : '播放BGM' }}
</button>
<button class="ctrl-btn" @click="playFanfare">
播放礼花音效
</button>
</div>
</div>
<!-- Emergency Reset -->
<div class="control-group danger-zone">
<h4>紧急操作</h4>
<div class="button-group">
<button class="ctrl-btn danger-outline" @click="openResetModal('voting')">
重置投票
</button>
<button class="ctrl-btn danger-outline" @click="openResetModal('lottery')">
重置抽奖
</button>
<button class="ctrl-btn danger" @click="openResetModal('all')">
全部重置
</button>
</div>
</div>
</div>
</section>
</main>
<!-- Reset Confirmation Modal -->
<div v-if="showResetModal" class="modal-overlay" @click.self="cancelReset">
<div class="modal">
<h3>确认重置</h3>
<p>
您即将重置
<strong>{{ resetScope === 'all' ? '所有数据' : resetScope === 'voting' ? '投票数据' : '抽奖数据' }}</strong>
</p>
<p class="warning-text">此操作不可撤销</p>
<div class="modal-input">
<label>请输入 <code>RESET</code> 确认:</label>
<input
v-model="confirmResetCode"
type="text"
placeholder="RESET"
@keyup.enter="confirmReset"
/>
</div>
<div class="modal-actions">
<button class="ctrl-btn outline" @click="cancelReset">取消</button>
<button
class="ctrl-btn danger"
:disabled="confirmResetCode !== 'RESET'"
@click="confirmReset"
>
确认重置
</button>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
// Industrial color palette
$admin-bg: #0d0d0d;
$admin-surface: #1a1a1a;
$admin-border: #333;
$admin-text: #e0e0e0;
$admin-text-muted: #888;
$admin-primary: #3b82f6;
$admin-success: #22c55e;
$admin-warning: #f59e0b;
$admin-danger: #ef4444;
.admin-control {
width: 100%;
height: 100%;
background: $admin-bg;
color: $admin-text;
overflow-y: auto;
}
// Header
.header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 24px;
background: $admin-surface;
border-bottom: 1px solid $admin-border;
position: sticky;
top: 0;
z-index: 100;
.back-btn {
background: transparent;
border: 1px solid $admin-border;
color: $admin-text-muted;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: $admin-text;
color: $admin-text;
}
}
.title {
font-size: 20px;
font-weight: 600;
flex: 1;
}
.status-bar {
display: flex;
align-items: center;
gap: 20px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: $admin-text-muted;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.online {
background: $admin-success;
box-shadow: 0 0 8px rgba($admin-success, 0.5);
}
&.offline {
background: #666;
}
}
.icon {
font-size: 16px;
}
&.phase-badge {
padding: 4px 12px;
border-radius: 4px;
font-weight: 500;
text-transform: uppercase;
font-size: 12px;
&.idle { background: #333; color: #888; }
&.voting { background: rgba($admin-primary, 0.2); color: $admin-primary; }
&.lottery { background: rgba($admin-warning, 0.2); color: $admin-warning; }
&.results { background: rgba($admin-success, 0.2); color: $admin-success; }
}
}
}
.content {
padding: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
// Error Toast
.error-toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: $admin-danger;
color: white;
padding: 12px 24px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
z-index: 200;
animation: slideDown 0.3s ease;
.close {
font-size: 18px;
opacity: 0.7;
}
}
@keyframes slideDown {
from { transform: translateX(-50%) translateY(-20px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
// Control Sections
.control-section {
background: $admin-surface;
border: 1px solid $admin-border;
border-radius: 12px;
overflow: hidden;
&.lottery-section {
grid-column: span 2;
}
&.global-section {
grid-column: span 2;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid $admin-border;
h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.section-status {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
background: #333;
color: #888;
&.active {
background: rgba($admin-success, 0.2);
color: $admin-success;
}
}
.round-indicator {
display: flex;
gap: 12px;
}
.round-badge {
padding: 4px 12px;
background: rgba($admin-warning, 0.2);
color: $admin-warning;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.phase-badge {
padding: 4px 12px;
background: #333;
color: #888;
border-radius: 4px;
font-size: 12px;
}
}
.section-body {
padding: 20px;
}
// Stats
.stat-row {
display: flex;
gap: 24px;
margin-bottom: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
.stat-label {
font-size: 12px;
color: $admin-text-muted;
text-transform: uppercase;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: $admin-text;
font-variant-numeric: tabular-nums;
}
}
// Button Groups
.button-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.ctrl-btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
border: 1px solid $admin-border;
background: #252525;
color: $admin-text;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #333;
border-color: #444;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&.active {
background: rgba($admin-primary, 0.2);
border-color: $admin-primary;
color: $admin-primary;
}
&.primary {
background: $admin-primary;
border-color: $admin-primary;
color: white;
&:hover:not(:disabled) {
background: lighten($admin-primary, 5%);
}
}
&.success {
background: $admin-success;
border-color: $admin-success;
color: white;
&:hover:not(:disabled) {
background: lighten($admin-success, 5%);
}
}
&.warning {
background: $admin-warning;
border-color: $admin-warning;
color: #000;
&:hover:not(:disabled) {
background: lighten($admin-warning, 5%);
}
}
&.danger {
background: $admin-danger;
border-color: $admin-danger;
color: white;
&:hover:not(:disabled) {
background: lighten($admin-danger, 5%);
}
}
&.danger-outline {
background: transparent;
border-color: $admin-danger;
color: $admin-danger;
&:hover:not(:disabled) {
background: rgba($admin-danger, 0.1);
}
}
&.outline {
background: transparent;
border-color: $admin-border;
color: $admin-text-muted;
&:hover:not(:disabled) {
border-color: $admin-text;
color: $admin-text;
}
}
&.large {
padding: 16px 40px;
font-size: 18px;
}
}
// Prize Info
.prize-info {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
margin-bottom: 20px;
.prize-level {
font-size: 20px;
font-weight: 700;
color: $color-gold;
}
.prize-name {
font-size: 16px;
color: $admin-text;
}
.prize-count {
font-size: 14px;
color: $admin-text-muted;
}
.prize-filter {
padding: 4px 12px;
background: rgba($admin-warning, 0.2);
color: $admin-warning;
border-radius: 4px;
font-size: 12px;
}
}
// Lottery Controls
.lottery-controls {
display: flex;
justify-content: center;
padding: 20px 0;
.countdown {
font-variant-numeric: tabular-nums;
}
}
.complete-controls {
display: flex;
gap: 16px;
}
// Winners Display
.winners-display {
margin-top: 20px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
h4 {
font-size: 14px;
color: $admin-text-muted;
margin: 0 0 12px 0;
}
}
.winners-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.winner-item {
display: flex;
flex-direction: column;
padding: 8px 16px;
background: rgba($color-gold, 0.1);
border: 1px solid rgba($color-gold, 0.3);
border-radius: 8px;
.winner-name {
font-size: 16px;
font-weight: 600;
color: $color-gold;
}
.winner-dept {
font-size: 12px;
color: $admin-text-muted;
}
}
// Round Progress
.round-progress {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid $admin-border;
}
.round-dot {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 14px;
font-weight: 600;
background: #252525;
border: 2px solid $admin-border;
color: $admin-text-muted;
&.active {
background: rgba($admin-warning, 0.2);
border-color: $admin-warning;
color: $admin-warning;
}
&.completed {
background: rgba($admin-success, 0.2);
border-color: $admin-success;
color: $admin-success;
}
}
// Control Groups
.control-group {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
h4 {
font-size: 12px;
color: $admin-text-muted;
text-transform: uppercase;
margin: 0 0 12px 0;
}
&.danger-zone {
padding-top: 20px;
border-top: 1px solid rgba($admin-danger, 0.3);
h4 {
color: $admin-danger;
}
}
}
// Modal
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: $admin-surface;
border: 1px solid $admin-border;
border-radius: 16px;
padding: 24px;
width: 400px;
max-width: 90vw;
h3 {
font-size: 18px;
margin: 0 0 16px 0;
}
p {
color: $admin-text-muted;
margin: 0 0 12px 0;
strong {
color: $admin-text;
}
}
.warning-text {
color: $admin-danger;
font-weight: 500;
}
}
.modal-input {
margin: 20px 0;
label {
display: block;
font-size: 14px;
color: $admin-text-muted;
margin-bottom: 8px;
code {
background: #333;
padding: 2px 6px;
border-radius: 4px;
color: $admin-danger;
}
}
input {
width: 100%;
padding: 12px;
background: #252525;
border: 1px solid $admin-border;
border-radius: 8px;
color: $admin-text;
font-size: 16px;
&:focus {
outline: none;
border-color: $admin-primary;
}
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
</style>