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>
This commit is contained in:
BIN
packages/client-mobile/src/assets/images/stamp-horse-2026.png
Normal file
BIN
packages/client-mobile/src/assets/images/stamp-horse-2026.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -44,83 +44,40 @@ const currentDate = computed(() => {
|
||||
opacity: inkOpacity,
|
||||
}"
|
||||
>
|
||||
<!-- Circular Date Stamp Design -->
|
||||
<!-- Simplified SVG with award name on top and nickname on bottom -->
|
||||
<svg
|
||||
viewBox="0 0 120 120"
|
||||
viewBox="0 0 100 100"
|
||||
class="postmark-svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Outer Ring -->
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="54"
|
||||
fill="none"
|
||||
:stroke="inkColor"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<!-- Double Ring Design (Mobile characteristic) -->
|
||||
<circle cx="50" cy="50" r="48" fill="none" :stroke="inkColor" stroke-width="2" />
|
||||
<circle cx="50" cy="50" r="42" fill="none" :stroke="inkColor" stroke-width="1" />
|
||||
|
||||
<!-- Inner Ring -->
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill="none"
|
||||
:stroke="inkColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
|
||||
<!-- Top Arc Text (Award Name) -->
|
||||
<!-- Text paths consistent with Big Screen -->
|
||||
<defs>
|
||||
<path
|
||||
id="topArc"
|
||||
d="M 15,60 A 45,45 0 0,1 105,60"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
id="bottomArc"
|
||||
d="M 105,60 A 45,45 0 0,1 15,60"
|
||||
fill="none"
|
||||
/>
|
||||
<path id="path-top" d="M 20,50 A 30,30 0 1,1 80,50" />
|
||||
<path id="path-bottom" d="M 80,50 A 30,30 0 0,1 20,50" />
|
||||
</defs>
|
||||
|
||||
<text
|
||||
:fill="inkColor"
|
||||
font-size="11"
|
||||
font-weight="bold"
|
||||
font-family="'Noto Serif SC', serif"
|
||||
letter-spacing="2"
|
||||
>
|
||||
<textPath href="#topArc" startOffset="50%" text-anchor="middle">
|
||||
<!-- Top Text: Award Name -->
|
||||
<text class="postmark-text top" :fill="inkColor">
|
||||
<textPath xlink:href="#path-top" startOffset="50%" text-anchor="middle">
|
||||
{{ awardName }}
|
||||
</textPath>
|
||||
</text>
|
||||
|
||||
<!-- Bottom Arc Text (Date & User) -->
|
||||
<text
|
||||
:fill="inkColor"
|
||||
font-size="8"
|
||||
font-family="'Courier New', monospace"
|
||||
>
|
||||
<textPath href="#bottomArc" startOffset="50%" text-anchor="middle">
|
||||
{{ currentDate }}{{ userName ? ` · ${userName}` : '' }}
|
||||
|
||||
<!-- Middle Text: Date -->
|
||||
<text x="50" y="50" class="postmark-text date" text-anchor="middle" dominant-baseline="central" :fill="inkColor">
|
||||
{{ currentDate }}
|
||||
</text>
|
||||
|
||||
<!-- Bottom Text: Nickname -->
|
||||
<text class="postmark-text bottom" :fill="inkColor">
|
||||
<textPath xlink:href="#path-bottom" startOffset="50%" text-anchor="middle">
|
||||
{{ userName || '访客' }}
|
||||
</textPath>
|
||||
</text>
|
||||
|
||||
<!-- Center Icon -->
|
||||
<text
|
||||
x="60"
|
||||
y="68"
|
||||
text-anchor="middle"
|
||||
font-size="28"
|
||||
class="center-icon"
|
||||
>
|
||||
{{ awardIcon }}
|
||||
</text>
|
||||
|
||||
<!-- Decorative Stars -->
|
||||
<text x="20" y="64" :fill="inkColor" font-size="8">★</text>
|
||||
<text x="95" y="64" :fill="inkColor" font-size="8">★</text>
|
||||
</svg>
|
||||
|
||||
<!-- Grunge Texture Overlay -->
|
||||
@@ -133,9 +90,9 @@ const currentDate = computed(() => {
|
||||
|
||||
.postmark {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
// CRITICAL: Multiply blend mode for ink absorption effect
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
// Multiply blend mode for realism
|
||||
mix-blend-mode: multiply;
|
||||
animation: stamp-reveal 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
@@ -147,34 +104,24 @@ const currentDate = computed(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.center-icon {
|
||||
// Emoji doesn't take fill color, but we can adjust opacity
|
||||
opacity: 0.9;
|
||||
.postmark-text {
|
||||
font-family: 'Kaiti', 'STKaiti', serif;
|
||||
font-weight: bold;
|
||||
|
||||
&.top { font-size: 11px; }
|
||||
&.date { font-size: 10px; letter-spacing: 0.5px; }
|
||||
&.bottom { font-size: 10px; }
|
||||
}
|
||||
|
||||
// Grunge texture for realistic ink imperfection
|
||||
.grunge-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.12'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.08'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
// Color variants
|
||||
.postmark--red {
|
||||
.postmark-svg {
|
||||
filter: drop-shadow(0 0 1px rgba(194, 31, 48, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
.postmark--gold {
|
||||
.postmark-svg {
|
||||
filter: drop-shadow(0 0 1px rgba(212, 168, 75, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stamp-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -3,7 +3,9 @@ import { computed, ref } from 'vue';
|
||||
import { useVotingStore, TICKET_INFO } from '../stores/voting';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import Postmark from './Postmark.vue';
|
||||
import stampImage from '../assets/images/stamp-horse-2026.png';
|
||||
import type { TicketType } from '@gala/shared/constants';
|
||||
import type { VoteStamp } from '@gala/shared/types';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
@@ -11,10 +13,14 @@ interface Props {
|
||||
teamName?: string;
|
||||
coverImage?: string;
|
||||
index?: number; // For stagger animation
|
||||
status?: 'pending' | 'voting' | 'completed';
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
index: 0,
|
||||
status: 'pending',
|
||||
isCurrent: false,
|
||||
});
|
||||
|
||||
const votingStore = useVotingStore();
|
||||
@@ -33,9 +39,23 @@ const stampInfo = computed(() => {
|
||||
return TICKET_INFO[stampedWith.value];
|
||||
});
|
||||
|
||||
// Can this card receive a stamp
|
||||
// Can this card receive a stamp (requires voting permission check)
|
||||
const canVote = computed(() => {
|
||||
const check = votingStore.canVoteForProgram(props.programId);
|
||||
return check.allowed;
|
||||
});
|
||||
|
||||
const canReceiveStamp = computed(() => {
|
||||
return votingStore.isStampSelected && !hasStamp.value;
|
||||
return votingStore.isStampSelected && !hasStamp.value && canVote.value;
|
||||
});
|
||||
|
||||
// Get voting status message
|
||||
const votingStatusLabel = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'voting': return '投票中';
|
||||
case 'completed': return '已结束';
|
||||
default: return '待投票';
|
||||
}
|
||||
});
|
||||
|
||||
// Stagger delay for entrance animation
|
||||
@@ -84,10 +104,16 @@ function delay(ms: number): Promise<void> {
|
||||
'has-stamp': hasStamp,
|
||||
'can-stamp': canReceiveStamp,
|
||||
'is-stamping': isStamping,
|
||||
'is-current': isCurrent,
|
||||
'is-disabled': !canVote && !hasStamp,
|
||||
}"
|
||||
:style="{ '--entrance-delay': entranceDelay }"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- Status Badge -->
|
||||
<div v-if="status !== 'pending'" class="status-badge" :class="status">
|
||||
{{ votingStatusLabel }}
|
||||
</div>
|
||||
<!-- Paper Texture Background -->
|
||||
<div class="paper-texture">
|
||||
<!-- Left: Cover Image (Picture Side) -->
|
||||
@@ -128,6 +154,10 @@ function delay(ms: number): Promise<void> {
|
||||
|
||||
<!-- Stamp Area -->
|
||||
<div class="stamp-zone">
|
||||
<!-- Real stamp image (always visible) -->
|
||||
<img :src="stampImage" alt="邮票" class="stamp-image" />
|
||||
|
||||
<!-- Postmark overlay on bottom-left of stamp -->
|
||||
<Postmark
|
||||
v-if="hasStamp && stampInfo"
|
||||
:award-name="stampInfo.name"
|
||||
@@ -136,10 +166,6 @@ function delay(ms: number): Promise<void> {
|
||||
color="red"
|
||||
class="applied-stamp"
|
||||
/>
|
||||
<div v-else class="stamp-placeholder">
|
||||
<span class="stamp-label">贴票处</span>
|
||||
<span class="placeholder-label">PLACE STAMP HERE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +203,7 @@ $ink-charcoal: #333;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.3s ease;
|
||||
|
||||
// Entrance animation
|
||||
animation: postcard-enter 0.5s ease-out backwards;
|
||||
@@ -197,6 +223,43 @@ $ink-charcoal: #333;
|
||||
background-color: color.adjust($paper-cream, $lightness: -2%);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
box-shadow: 0 4px 25px rgba($color-gold, 0.4), 0 0 0 3px $color-gold;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
z-index: 5;
|
||||
|
||||
&.voting {
|
||||
background: $color-gold;
|
||||
color: #000;
|
||||
animation: pulse-badge 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.paper-texture {
|
||||
@@ -334,13 +397,24 @@ $ink-charcoal: #333;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stamp-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.applied-stamp {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: -10px;
|
||||
mix-blend-mode: multiply;
|
||||
transform: rotate(5deg);
|
||||
transform: rotate(-8deg);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stamp-placeholder {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ConnectionAckPayload,
|
||||
VoteCategory,
|
||||
SyncStatePayload,
|
||||
AdminState,
|
||||
} from '@gala/shared/types';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
import { CONFIG } from '@gala/shared/constants';
|
||||
@@ -24,6 +25,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
const reconnectAttempts = ref(0);
|
||||
const userId = ref<string | null>(null);
|
||||
const userName = ref<string | null>(null);
|
||||
const department = ref<string | null>(null);
|
||||
const votedCategories = ref<VoteCategory[]>([]);
|
||||
|
||||
// Computed
|
||||
@@ -116,6 +118,29 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
votedCategories.value = data.userVotedCategories;
|
||||
}
|
||||
});
|
||||
// Admin state sync - update voting state
|
||||
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
|
||||
console.log('[Socket] Admin state sync received');
|
||||
// Dynamically import voting store to avoid circular dependency
|
||||
import('./voting').then(({ useVotingStore }) => {
|
||||
const votingStore = useVotingStore();
|
||||
votingStore.syncVotingState({
|
||||
votingOpen: state.voting.subPhase === 'OPEN',
|
||||
votingPaused: state.voting.subPhase === 'PAUSED',
|
||||
programs: state.voting.programs,
|
||||
currentProgramId: state.voting.currentProgramId,
|
||||
allowLateCatch: state.voting.allowLateCatch,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Remote vote update (from other users)
|
||||
socketInstance.on(SOCKET_EVENTS.VOTE_UPDATED as any, (data: any) => {
|
||||
import('./voting').then(({ useVotingStore }) => {
|
||||
const votingStore = useVotingStore();
|
||||
votingStore.handleVoteUpdate(data);
|
||||
});
|
||||
});
|
||||
|
||||
socket.value = socketInstance as GalaSocket;
|
||||
}
|
||||
@@ -131,6 +156,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
{
|
||||
userId: userId.value,
|
||||
userName: userName.value || 'Guest',
|
||||
department: department.value || '未知部门',
|
||||
role: 'user',
|
||||
},
|
||||
(response: any) => {
|
||||
@@ -193,9 +219,10 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
/**
|
||||
* Set user info
|
||||
*/
|
||||
function setUser(id: string, name: string) {
|
||||
function setUser(id: string, name: string, dept: string) {
|
||||
userId.value = id;
|
||||
userName.value = name;
|
||||
department.value = dept;
|
||||
|
||||
// Rejoin if already connected
|
||||
if (socket.value?.connected) {
|
||||
@@ -237,6 +264,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
reconnectAttempts,
|
||||
userId,
|
||||
userName,
|
||||
department,
|
||||
votedCategories,
|
||||
|
||||
// Computed
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
||||
import { useConnectionStore } from './connection';
|
||||
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
|
||||
import { showToast } from 'vant';
|
||||
import type { VoteStamp } from '@gala/shared/types';
|
||||
|
||||
// Ticket display info
|
||||
export const TICKET_INFO: Record<TicketType, { name: string; icon: string }> = {
|
||||
@@ -21,6 +22,15 @@ interface PendingVote {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface VotingProgram {
|
||||
id: string;
|
||||
name: string;
|
||||
teamName: string;
|
||||
order: number;
|
||||
status: 'pending' | 'voting' | 'completed';
|
||||
votes: number;
|
||||
}
|
||||
|
||||
export const useVotingStore = defineStore('voting', () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
@@ -35,6 +45,13 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
potential: null,
|
||||
});
|
||||
|
||||
// Server-synced voting state
|
||||
const votingOpen = ref(false);
|
||||
const votingPaused = ref(false);
|
||||
const programs = ref<VotingProgram[]>([]);
|
||||
const currentProgramId = ref<string | null>(null);
|
||||
const allowLateCatch = ref(true);
|
||||
|
||||
// Currently selected stamp in dock
|
||||
const selectedStamp = ref<TicketType | null>(null);
|
||||
|
||||
@@ -211,6 +228,63 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
tickets.value = { ...serverTickets };
|
||||
}
|
||||
|
||||
// Sync voting state from AdminState
|
||||
function syncVotingState(state: {
|
||||
votingOpen?: boolean;
|
||||
votingPaused?: boolean;
|
||||
programs?: VotingProgram[];
|
||||
currentProgramId?: string | null;
|
||||
allowLateCatch?: boolean;
|
||||
}) {
|
||||
if (state.votingOpen !== undefined) votingOpen.value = state.votingOpen;
|
||||
if (state.votingPaused !== undefined) votingPaused.value = state.votingPaused;
|
||||
if (state.programs !== undefined) programs.value = state.programs;
|
||||
if (state.currentProgramId !== undefined) currentProgramId.value = state.currentProgramId;
|
||||
if (state.allowLateCatch !== undefined) allowLateCatch.value = state.allowLateCatch;
|
||||
}
|
||||
|
||||
// Check if voting is allowed for a specific program
|
||||
// In unified voting mode, all programs can be voted when voting is open
|
||||
function canVoteForProgram(programId: string): { allowed: boolean; reason?: string } {
|
||||
// Check if voting is open
|
||||
if (!votingOpen.value) {
|
||||
return { allowed: false, reason: '投票尚未开始' };
|
||||
}
|
||||
if (votingPaused.value) {
|
||||
return { allowed: false, reason: '投票已暂停' };
|
||||
}
|
||||
|
||||
// In unified voting mode, all programs are votable when voting is open
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Handle real-time vote updates
|
||||
function handleVoteUpdate(data: { candidateId: string; newCount: number; totalVotes: number }) {
|
||||
const program = programs.value.find(p => p.id === data.candidateId);
|
||||
if (program) {
|
||||
program.votes = data.newCount;
|
||||
}
|
||||
// Update global total votes if provided
|
||||
if (data.totalVotes !== undefined) {
|
||||
// We don't have a totalVotes ref in the store yet, but we could add it or just ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Get current voting program
|
||||
const currentProgram = computed(() => {
|
||||
if (!currentProgramId.value) return null;
|
||||
return programs.value.find(p => p.id === currentProgramId.value) || null;
|
||||
});
|
||||
|
||||
// Get votable programs (voting or completed with late catch)
|
||||
const votablePrograms = computed(() => {
|
||||
return programs.value.filter(p => {
|
||||
if (p.status === 'voting') return true;
|
||||
if (p.status === 'completed' && allowLateCatch.value) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tickets,
|
||||
selectedStamp,
|
||||
@@ -225,5 +299,17 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
castVote,
|
||||
revokeVote,
|
||||
syncFromServer,
|
||||
// New exports
|
||||
votingOpen,
|
||||
votingPaused,
|
||||
programs,
|
||||
currentProgramId,
|
||||
allowLateCatch,
|
||||
currentProgram,
|
||||
votablePrograms,
|
||||
syncVotingState,
|
||||
canVoteForProgram,
|
||||
handleVoteUpdate,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const router = useRouter();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const userName = ref('');
|
||||
const userDept = ref('技术部');
|
||||
const isLoading = ref(false);
|
||||
|
||||
async function handleEnter() {
|
||||
@@ -21,7 +22,7 @@ async function handleEnter() {
|
||||
// Generate a simple user ID (in production, this would come from auth)
|
||||
const odrawId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
connectionStore.setUser(odrawId, userName.value.trim());
|
||||
connectionStore.setUser(odrawId, userName.value.trim(), userDept.value);
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
@@ -61,6 +62,17 @@ async function handleEnter() {
|
||||
@keyup.enter="handleEnter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper guochao-border">
|
||||
<van-field
|
||||
v-model="userDept"
|
||||
placeholder="请输入您的部门"
|
||||
:border="false"
|
||||
clearable
|
||||
maxlength="20"
|
||||
@keyup.enter="handleEnter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
class="enter-btn"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useVotingStore } from '../stores/voting';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import VotingDock from '../components/VotingDock.vue';
|
||||
@@ -8,19 +8,29 @@ import ProgramCard from '../components/ProgramCard.vue';
|
||||
const votingStore = useVotingStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
// 节目列表(从后端获取,这里先用 mock 数据)
|
||||
const programs = ref([
|
||||
{ id: 'p1', name: '龙腾四海', team: '市场部', coverImage: '' },
|
||||
{ id: 'p2', name: '金马奔腾', team: '技术部', coverImage: '' },
|
||||
{ id: 'p3', name: '春风得意', team: '人力资源部', coverImage: '' },
|
||||
{ id: 'p4', name: '鸿运当头', team: '财务部', coverImage: '' },
|
||||
{ id: 'p5', name: '马到成功', team: '运营部', coverImage: '' },
|
||||
{ id: 'p6', name: '一马当先', team: '产品部', coverImage: '' },
|
||||
{ id: 'p7', name: '万马奔腾', team: '设计部', coverImage: '' },
|
||||
{ id: 'p8', name: '龙马精神', team: '销售部', coverImage: '' },
|
||||
]);
|
||||
// Use server-synced programs from voting store
|
||||
const programs = computed(() => {
|
||||
// If no programs from server, show default list
|
||||
if (votingStore.programs.length === 0) {
|
||||
return [
|
||||
{ id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending' as const, votes: 0 },
|
||||
];
|
||||
}
|
||||
return votingStore.programs;
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
// Voting status message
|
||||
const votingStatusMessage = computed(() => {
|
||||
if (!votingStore.votingOpen) return '投票尚未开始';
|
||||
return '投票进行中';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!connectionStore.isConnected) {
|
||||
@@ -44,15 +54,23 @@ onMounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Voting Status Bar -->
|
||||
<div class="voting-status-bar" :class="{ active: votingStore.votingOpen }">
|
||||
<span class="status-dot" :class="{ pulsing: votingStore.votingOpen }"></span>
|
||||
<span class="status-text">{{ votingStatusMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Program List -->
|
||||
<main class="program-list">
|
||||
<ProgramCard
|
||||
v-for="program in programs"
|
||||
v-for="(program, index) in programs"
|
||||
:key="program.id"
|
||||
:program-id="program.id"
|
||||
:program-name="program.name"
|
||||
:team-name="program.team"
|
||||
:cover-image="program.coverImage"
|
||||
:team-name="program.teamName"
|
||||
:index="index"
|
||||
:status="program.status"
|
||||
:is-current="program.id === votingStore.currentProgramId"
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -132,4 +150,51 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
// Voting Status Bar
|
||||
.voting-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.active {
|
||||
background: rgba($color-gold, 0.1);
|
||||
border-color: rgba($color-gold, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
|
||||
&.pulsing {
|
||||
background: #22c55e;
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
50% { opacity: 0.8; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.late-catch-hint {
|
||||
font-size: 10px;
|
||||
color: $color-gold;
|
||||
padding: 2px 8px;
|
||||
background: rgba($color-gold, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
BIN
packages/client-screen/src/assets/images/stamp-horse-2026.png
Normal file
BIN
packages/client-screen/src/assets/images/stamp-horse-2026.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
466
packages/client-screen/src/components/PostcardDisplay.vue
Normal file
466
packages/client-screen/src/components/PostcardDisplay.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { VotingProgram, VoteStamp } from '@gala/shared/types';
|
||||
import Postmark from './Postmark.vue';
|
||||
|
||||
// 票据类型名称映射
|
||||
const TICKET_TYPE_NAMES: Record<string, string> = {
|
||||
creative: '最佳创意',
|
||||
visual: '最佳视觉',
|
||||
atmosphere: '最佳氛围',
|
||||
performance: '最佳表演',
|
||||
teamwork: '最佳团队',
|
||||
popularity: '最受欢迎',
|
||||
potential: '最具潜力',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
program: VotingProgram;
|
||||
isFocused: boolean;
|
||||
showStamps?: boolean;
|
||||
compact?: boolean; // 紧凑模式用于网格布局
|
||||
rotateX?: number; // 3D 旋转 X
|
||||
rotateY?: number; // 3D 旋转 Y
|
||||
rotateZ?: number; // 3D 旋转 Z
|
||||
z?: number; // 3D 位移 Z
|
||||
index?: number; // 传入索引用于生成邮编
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showStamps: true,
|
||||
compact: false,
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
rotateZ: 0,
|
||||
z: 0,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
// 计算邮政编码 (模拟 202601, 202602...)
|
||||
const postcodeDigits = computed(() => {
|
||||
const code = (202601 + props.index).toString();
|
||||
return code.split('');
|
||||
});
|
||||
|
||||
// 在紧凑模式下不进行缩放和模糊
|
||||
const scale = computed(() => props.compact ? 1 : (props.isFocused ? 1 : 0.65));
|
||||
const blur = computed(() => props.compact ? 0 : (props.isFocused ? 0 : 4));
|
||||
const opacity = computed(() => props.compact ? 1 : (props.isFocused ? 1 : 0.6));
|
||||
|
||||
// 3D 变换样式
|
||||
const transformStyle = computed(() => {
|
||||
if (props.compact) {
|
||||
return `rotateX(${props.rotateX}deg) rotateY(${props.rotateY}deg) rotateZ(${props.rotateZ}deg) translateZ(${props.z}px)`;
|
||||
}
|
||||
return `scale(${scale.value})`;
|
||||
});
|
||||
|
||||
// 格式化印章位置样式
|
||||
function getStampStyle(stamp: VoteStamp) {
|
||||
return {
|
||||
left: `${stamp.x}%`,
|
||||
top: `${stamp.y}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${stamp.rotation}deg)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 根据 ticketType 获取颜色
|
||||
function getStampColor(ticketType: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
'best_performance': '#e8313f',
|
||||
'best_creativity': '#f97316',
|
||||
'best_visual': '#10b981',
|
||||
'best_humor': '#eab308',
|
||||
'most_touching': '#ec4899',
|
||||
'best_teamwork': '#3b82f6',
|
||||
'audience_favorite': '#d4af37',
|
||||
};
|
||||
return colors[ticketType] || '#e8313f';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="postcard-display"
|
||||
:class="{ 'is-focused': isFocused, 'is-blurred': !isFocused }"
|
||||
:style="{
|
||||
'--blur': `${blur}px`,
|
||||
'--opacity': opacity,
|
||||
'transform': transformStyle,
|
||||
}"
|
||||
>
|
||||
<!-- 明信片主体 -->
|
||||
<div class="postcard-body">
|
||||
<!-- 邮票 (右上角) -->
|
||||
<div class="postage-stamp">
|
||||
<img src="/Users/yuanjiantsui/.gemini/antigravity/brain/0695d84c-9b47-48d6-aefb-6d219a9324c4/year_of_horse_stamp_1768535779377.png" alt="Stamp" />
|
||||
</div>
|
||||
|
||||
<!-- 邮编框 (左上角) -->
|
||||
<div class="postcode-container">
|
||||
<div v-for="(digit, i) in postcodeDigits" :key="i" class="postcode-box">
|
||||
{{ digit }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-layout">
|
||||
<!-- 左侧:节目名与祝福语 -->
|
||||
<div class="left-section">
|
||||
<div class="program-name-container">
|
||||
<h2 class="program-title">{{ program.name }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="blessings-outer">
|
||||
<div class="blessings-box">
|
||||
<span class="blessings-text handwritten">With all our passion 倾情呈现</span>
|
||||
</div>
|
||||
<span class="blessings-label">祝福语</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:地址栏 -->
|
||||
<div class="right-section address-layout">
|
||||
<div class="address-line">
|
||||
<span class="address-prefix">TO:</span>
|
||||
<span class="address-content handwritten">全体同事</span>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div class="address-line">
|
||||
<span class="address-prefix">FROM:</span>
|
||||
<span class="address-content handwritten">{{ program.teamName }}</span>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div class="address-line name-line">
|
||||
<span class="address-content handwritten">The Gala Family</span>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 印刷单位 (左下角) -->
|
||||
<div class="manufacturer-mark">
|
||||
四川省邮电印制有限责任公司
|
||||
</div>
|
||||
|
||||
<!-- 票数统计 (可选,作为年会互动元素保留) -->
|
||||
<div class="vote-tag">
|
||||
<span class="vote-number">{{ program.votes }}</span>
|
||||
<span class="vote-unit">票</span>
|
||||
</div>
|
||||
|
||||
<!-- 盖章区域 (置于最顶层以修复 Bug) -->
|
||||
<div v-if="showStamps" class="stamps-overlay">
|
||||
<div
|
||||
v-for="stamp in program.stamps.slice(-20)"
|
||||
:key="stamp.id"
|
||||
class="stamp-mark"
|
||||
:style="getStampStyle(stamp)"
|
||||
>
|
||||
<Postmark
|
||||
:award-name="TICKET_TYPE_NAMES[stamp.ticketType] || stamp.ticketType"
|
||||
:user-name="stamp.userName"
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聚焦时的发光边框 -->
|
||||
<div v-if="isFocused" class="focus-glow"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use 'sass:color';
|
||||
|
||||
// 核心变量
|
||||
$paper-cream: #f9f6ef;
|
||||
$ink-blue: #1b3a7a;
|
||||
$ink-charcoal: #2c3e50;
|
||||
$stamp-red: #c0392b;
|
||||
$color-gold: #d4af37;
|
||||
|
||||
.postcard-display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 380px;
|
||||
position: relative;
|
||||
transition: all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
filter: blur(var(--blur));
|
||||
opacity: var(--opacity);
|
||||
transform-style: preserve-3d;
|
||||
|
||||
&.is-focused {
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.postcard-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: $paper-cream;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.2),
|
||||
0 1px 2px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
|
||||
// 更加真实的纸张纹理
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)),
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
// 邮编框
|
||||
.postcode-container {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
left: 25px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.postcode-box {
|
||||
width: 28px;
|
||||
height: 36px;
|
||||
border: 1px solid $stamp-red;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: $stamp-red;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
// 邮票
|
||||
.postage-stamp {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
width: 85px;
|
||||
height: 100px;
|
||||
z-index: 5; // 普通层级
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(2px 2px 5px rgba(0,0,0,0.2));
|
||||
}
|
||||
}
|
||||
|
||||
// 主布局
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
margin-top: 40px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.left-section {
|
||||
flex: 1.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.program-name-container {
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.program-title {
|
||||
font-size: 38px;
|
||||
margin: 0;
|
||||
color: $ink-charcoal;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-weight: 900;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.blessings-outer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.blessings-box {
|
||||
border: 1px solid #999;
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.blessings-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 24px;
|
||||
color: rgba(0,0,0,0.05);
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.right-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 25px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.address-line {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
|
||||
.address-prefix {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.address-content {
|
||||
font-size: 22px;
|
||||
color: $ink-blue;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 55px;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #bbb;
|
||||
}
|
||||
|
||||
&.name-line {
|
||||
.address-content {
|
||||
padding-left: 55px;
|
||||
}
|
||||
.line {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手写体
|
||||
.handwritten {
|
||||
font-family: 'Ma Shan Zheng', 'Kaiti', cursive;
|
||||
}
|
||||
|
||||
// 印刷厂标记
|
||||
.manufacturer-mark {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 30px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
// 投票标签
|
||||
.vote-tag {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 30px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
background: rgba($color-gold, 0.1);
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba($color-gold, 0.3);
|
||||
|
||||
.vote-number {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: darken($color-gold, 15%);
|
||||
}
|
||||
|
||||
.vote-unit {
|
||||
font-size: 12px;
|
||||
color: $color-gold;
|
||||
}
|
||||
}
|
||||
|
||||
// 盖章区域 (置于最顶层)
|
||||
.stamps-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 100; // 确保在邮票和所有其他内容上方
|
||||
}
|
||||
|
||||
.stamp-mark {
|
||||
position: absolute;
|
||||
animation: stamp-appear 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.stamp-circle {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--stamp-color);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
mix-blend-mode: multiply;
|
||||
box-shadow: inset 0 0 10px var(--stamp-color);
|
||||
|
||||
.stamp-user {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--stamp-color);
|
||||
text-shadow: 0 0 1px white;
|
||||
}
|
||||
}
|
||||
|
||||
.focus-glow {
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border: 3px solid $color-gold;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 40px rgba($color-gold, 0.6);
|
||||
pointer-events: none;
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@keyframes stamp-appear {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
154
packages/client-screen/src/components/PostcardGrid.vue
Normal file
154
packages/client-screen/src/components/PostcardGrid.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import PostcardItem from './PostcardItem.vue';
|
||||
import type { VotingProgram } from '@gala/shared/types';
|
||||
|
||||
interface Props {
|
||||
programs: VotingProgram[];
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
columns: 4,
|
||||
rows: 2,
|
||||
});
|
||||
|
||||
// Calculate visible programs based on grid size
|
||||
const maxVisible = computed(() => props.columns * props.rows);
|
||||
|
||||
const visiblePrograms = computed(() => {
|
||||
return props.programs.slice(0, maxVisible.value);
|
||||
});
|
||||
|
||||
// Refs for postcard items (for stamp animation targeting)
|
||||
const postcardRefs = ref<Map<string, InstanceType<typeof PostcardItem>>>(new Map());
|
||||
|
||||
function setPostcardRef(id: string, el: any) {
|
||||
if (el) {
|
||||
postcardRefs.value.set(id, el);
|
||||
} else {
|
||||
postcardRefs.value.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Get stamp target element for a specific program
|
||||
function getStampTarget(programId: string): HTMLElement | null {
|
||||
const postcard = postcardRefs.value.get(programId);
|
||||
return postcard?.stampTargetRef || null;
|
||||
}
|
||||
|
||||
// Expose for parent component (animation system)
|
||||
defineExpose({
|
||||
getStampTarget,
|
||||
postcardRefs,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="postcard-grid"
|
||||
:style="{
|
||||
'--columns': columns,
|
||||
'--rows': rows,
|
||||
}"
|
||||
>
|
||||
<PostcardItem
|
||||
v-for="program in visiblePrograms"
|
||||
:key="program.id"
|
||||
:ref="(el) => setPostcardRef(program.id, el)"
|
||||
:id="program.id"
|
||||
:name="program.name"
|
||||
:team-name="program.teamName"
|
||||
:order="program.order"
|
||||
:votes="program.votes"
|
||||
:stamps="program.stamps"
|
||||
class="grid-item"
|
||||
/>
|
||||
|
||||
<!-- Empty slots for incomplete grid -->
|
||||
<div
|
||||
v-for="n in Math.max(0, maxVisible - visiblePrograms.length)"
|
||||
:key="`empty-${n}`"
|
||||
class="grid-item empty-slot"
|
||||
>
|
||||
<div class="empty-placeholder">
|
||||
<span class="empty-icon">📮</span>
|
||||
<span class="empty-text">待添加节目</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.postcard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns, 4), 1fr);
|
||||
grid-template-rows: repeat(var(--rows, 2), 1fr);
|
||||
gap: 24px;
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #FDFBF7;
|
||||
|
||||
// Subtle paper texture for the entire grid background
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
background-blend-mode: overlay;
|
||||
|
||||
// Responsive breakpoints
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: auto;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-slot {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #bbb;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
font-family: 'SimSun', 'Songti SC', serif;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
354
packages/client-screen/src/components/PostcardItem.vue
Normal file
354
packages/client-screen/src/components/PostcardItem.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { VoteStamp } from '@gala/shared/types';
|
||||
import stampImage from '../assets/images/stamp-horse-2026.png';
|
||||
import Postmark from './Postmark.vue';
|
||||
|
||||
// 票据类型名称映射
|
||||
const TICKET_TYPE_NAMES: Record<string, string> = {
|
||||
creative: '最佳创意',
|
||||
visual: '最佳视觉',
|
||||
atmosphere: '最佳氛围',
|
||||
performance: '最佳表演',
|
||||
teamwork: '最佳团队',
|
||||
popularity: '最受欢迎',
|
||||
potential: '最具潜力',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
teamName: string;
|
||||
order: number;
|
||||
votes: number;
|
||||
stamps?: VoteStamp[];
|
||||
slogan?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
slogan: 'With all our passion',
|
||||
stamps: () => [],
|
||||
});
|
||||
|
||||
// Generate zip code display: 2|0|2|6|0|[order]
|
||||
const zipCodes = computed(() => {
|
||||
const orderStr = String(props.order).padStart(1, '0');
|
||||
return ['2', '0', '2', '6', '0', orderStr];
|
||||
});
|
||||
|
||||
// Limit displayed stamps to avoid overcrowding
|
||||
const displayedStamps = computed(() => {
|
||||
return props.stamps?.slice(-12) || []; // Show last 12 stamps max
|
||||
});
|
||||
|
||||
// Random position for new stamps (avoiding center content area)
|
||||
function getStampStyle(stamp: VoteStamp) {
|
||||
const isNew = Date.now() - stamp.timestamp < 1000;
|
||||
|
||||
// For fly-in animation
|
||||
const flyX = (Math.random() - 0.5) * 1000; // -500 to 500
|
||||
const flyY = (Math.random() - 0.5) * 1000;
|
||||
const flyRotate = (Math.random() - 0.5) * 180;
|
||||
|
||||
return {
|
||||
left: `${stamp.x}%`,
|
||||
top: `${stamp.y}%`,
|
||||
transform: isNew ? undefined : `translate(-50%, -50%) rotate(${stamp.rotation}deg)`,
|
||||
'--fly-x': `${flyX}px`,
|
||||
'--fly-y': `${flyY}px`,
|
||||
'--fly-rotate': `${flyRotate}deg`,
|
||||
};
|
||||
}
|
||||
|
||||
// Stamp target ref for particle animation
|
||||
const stampTargetRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Award name mapping for stamps
|
||||
const awardNames: Record<string, string> = {
|
||||
creative: '最佳创意奖',
|
||||
visual: '最佳视觉奖',
|
||||
atmosphere: '最佳气氛奖',
|
||||
performance: '最佳表演奖',
|
||||
teamwork: '最佳团队奖',
|
||||
popularity: '最受欢迎奖',
|
||||
potential: '最具潜力奖',
|
||||
};
|
||||
|
||||
function getAwardName(type: string) {
|
||||
return awardNames[type] || '优秀节目奖';
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
stampTargetRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="postcard">
|
||||
<!-- Accumulated Stamps Layer (on top) -->
|
||||
<div class="stamps-layer">
|
||||
<div
|
||||
v-for="stamp in displayedStamps"
|
||||
:key="stamp.id"
|
||||
class="postmark-wrapper"
|
||||
:class="{ 'is-new': Date.now() - stamp.timestamp < 1000 }"
|
||||
:style="getStampStyle(stamp)"
|
||||
>
|
||||
<Postmark
|
||||
:award-name="getAwardName(stamp.ticketType)"
|
||||
:user-name="stamp.userName"
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Row: Zip codes left, Stamp right -->
|
||||
<div class="top-row">
|
||||
<div class="zip-codes">
|
||||
<div v-for="(code, idx) in zipCodes" :key="idx" class="zip-box">
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
<div ref="stampTargetRef" class="stamp-box">
|
||||
<img :src="stampImage" alt="2026马年邮票" class="stamp-image" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Row: Left content, Right address -->
|
||||
<div class="content-row">
|
||||
<!-- Left Side: Title + Slogan -->
|
||||
<div class="content-left">
|
||||
<h2 class="program-name">{{ name }}</h2>
|
||||
<div class="slogan-box">
|
||||
<span class="slogan-text">{{ slogan }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Address lines -->
|
||||
<div class="content-right">
|
||||
<div class="address-zone">
|
||||
<div class="address-line">
|
||||
<span class="label">寄:</span>
|
||||
<span class="value">{{ teamName }}</span>
|
||||
</div>
|
||||
<div class="address-line">
|
||||
<span class="label">收:</span>
|
||||
<span class="value">2026全体家人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer-zone">
|
||||
<span class="vote-count">{{ votes }} 票</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Postcard base - Chinese postcard style (wider)
|
||||
.postcard {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.5 / 1;
|
||||
background: #FDFBF7;
|
||||
border: 1px solid #2c2c2c;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// Paper texture
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%' height='100%' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
opacity: 0.03;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Stamps layer (accumulated postmarks)
|
||||
.stamps-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.postmark-wrapper {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
&.is-new {
|
||||
animation: stamp-fly-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stamp-fly-in {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) translate(var(--fly-x, -200px), var(--fly-y, -200px)) scale(3) rotate(var(--fly-rotate, -45deg));
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
// Top Row: Zip codes + Stamp
|
||||
.top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.zip-codes {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.zip-box {
|
||||
width: 22px;
|
||||
height: 26px;
|
||||
border: 1.5px solid #c41e3a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: #2c2c2c;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stamp-box {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.stamp-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
// Content Row
|
||||
.content-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Left Side: Title + Slogan
|
||||
.content-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.program-name {
|
||||
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #c41e3a;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: 6px;
|
||||
}
|
||||
|
||||
.slogan-box {
|
||||
display: inline-block;
|
||||
border: 1px solid #ccc;
|
||||
padding: 6px 14px;
|
||||
align-self: flex-start;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.slogan-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// Right Side: Address
|
||||
.content-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.address-zone {
|
||||
text-align: left;
|
||||
border-top: 1px solid #999;
|
||||
padding-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.address-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'SimSun', 'Songti SC', serif;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: 'Kaiti', 'STKaiti', serif;
|
||||
font-size: 13px;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer Zone
|
||||
.footer-zone {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.vote-count {
|
||||
font-family: 'SimSun', 'Songti SC', serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #c41e3a;
|
||||
padding: 4px 12px;
|
||||
background: rgba(196, 30, 58, 0.08);
|
||||
border: 1px solid rgba(196, 30, 58, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
packages/client-screen/src/components/Postmark.vue
Normal file
139
packages/client-screen/src/components/Postmark.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
awardName: string;
|
||||
awardIcon?: string;
|
||||
userName?: string;
|
||||
color?: 'red' | 'gold';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
awardIcon: '🏅',
|
||||
userName: '',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
// Random imperfections for realism
|
||||
const rotation = ref(0);
|
||||
const inkOpacity = ref(0.9);
|
||||
|
||||
onMounted(() => {
|
||||
// Random rotation between -15 and +15 degrees
|
||||
rotation.value = (Math.random() - 0.5) * 30;
|
||||
// Random opacity between 0.85 and 0.95
|
||||
inkOpacity.value = 0.85 + Math.random() * 0.1;
|
||||
});
|
||||
|
||||
const inkColor = computed(() => {
|
||||
return props.color === 'gold' ? '#D4A84B' : '#C21F30';
|
||||
});
|
||||
|
||||
const currentDate = computed(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="postmark"
|
||||
:class="[`postmark--${color}`]"
|
||||
:style="{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: inkOpacity,
|
||||
}"
|
||||
>
|
||||
<!-- Simplified SVG with award name on top and nickname on bottom -->
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
class="postmark-svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Double Ring Design (Mobile characteristic) -->
|
||||
<circle cx="50" cy="50" r="48" fill="none" :stroke="inkColor" stroke-width="2" />
|
||||
<circle cx="50" cy="50" r="42" fill="none" :stroke="inkColor" stroke-width="1" />
|
||||
|
||||
<!-- Text paths consistent with Big Screen -->
|
||||
<defs>
|
||||
<path id="path-top" d="M 20,50 A 30,30 0 1,1 80,50" />
|
||||
<path id="path-bottom" d="M 80,50 A 30,30 0 0,1 20,50" />
|
||||
</defs>
|
||||
|
||||
<!-- Top Text: Award Name -->
|
||||
<text class="postmark-text top" :fill="inkColor">
|
||||
<textPath xlink:href="#path-top" startOffset="50%" text-anchor="middle">
|
||||
{{ awardName }}
|
||||
</textPath>
|
||||
</text>
|
||||
|
||||
<!-- Middle Text: Date -->
|
||||
<text x="50" y="50" class="postmark-text date" text-anchor="middle" dominant-baseline="central" :fill="inkColor">
|
||||
{{ currentDate }}
|
||||
</text>
|
||||
|
||||
<!-- Bottom Text: Nickname -->
|
||||
<text class="postmark-text bottom" :fill="inkColor">
|
||||
<textPath xlink:href="#path-bottom" startOffset="50%" text-anchor="middle">
|
||||
{{ userName || '访客' }}
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<!-- Grunge Texture Overlay -->
|
||||
<div class="grunge-overlay"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
.postmark {
|
||||
position: relative;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
// Multiply blend mode for realism
|
||||
mix-blend-mode: multiply;
|
||||
animation: stamp-reveal 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.postmark-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.postmark-text {
|
||||
font-family: 'Kaiti', 'STKaiti', serif;
|
||||
font-weight: bold;
|
||||
|
||||
&.top { font-size: 11px; }
|
||||
&.date { font-size: 10px; letter-spacing: 0.5px; }
|
||||
&.bottom { font-size: 10px; }
|
||||
}
|
||||
|
||||
.grunge-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.08'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes stamp-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(1.2) rotate(var(--rotation, 0deg));
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(0.95) rotate(var(--rotation, 0deg));
|
||||
}
|
||||
100% {
|
||||
opacity: var(--ink-opacity, 0.9);
|
||||
transform: scale(1) rotate(var(--rotation, 0deg));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,9 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
const votingOpen = ref(false);
|
||||
const votingPaused = ref(false);
|
||||
const totalVotes = ref(0);
|
||||
const programs = ref<Array<{ id: string; name: string; teamName: string; order: number; status: string; votes: number; stamps: any[] }>>([]);
|
||||
const allowLateCatch = ref(true);
|
||||
const currentProgramId = ref<string | null>(null);
|
||||
|
||||
// Lottery State
|
||||
const lotteryRound = ref<LotteryRound>(1);
|
||||
@@ -143,7 +146,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
userId: 'admin_main',
|
||||
userName: 'Admin Console',
|
||||
role: 'admin',
|
||||
}, () => {});
|
||||
}, () => { });
|
||||
|
||||
// Request state sync
|
||||
socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, {});
|
||||
@@ -177,6 +180,9 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
votingOpen.value = state.voting.subPhase === 'OPEN';
|
||||
votingPaused.value = state.voting.subPhase === 'PAUSED';
|
||||
totalVotes.value = state.voting.totalVotes;
|
||||
programs.value = state.voting.programs || [];
|
||||
allowLateCatch.value = state.voting.allowLateCatch ?? true;
|
||||
currentProgramId.value = state.voting.currentProgramId || null;
|
||||
lotteryRound.value = state.lottery.round;
|
||||
lotterySubPhase.value = state.lottery.subPhase;
|
||||
currentWinners.value = state.lottery.currentWinners;
|
||||
@@ -361,6 +367,9 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
votingOpen,
|
||||
votingPaused,
|
||||
totalVotes,
|
||||
programs,
|
||||
allowLateCatch,
|
||||
currentProgramId,
|
||||
lotteryRound,
|
||||
lotterySubPhase,
|
||||
stormStartedAt,
|
||||
@@ -387,5 +396,6 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
emergencyReset,
|
||||
controlMusic,
|
||||
clearError,
|
||||
getSocket: () => socket.value,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -43,6 +43,41 @@ 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');
|
||||
@@ -224,6 +259,30 @@ onUnmounted(() => {
|
||||
</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">
|
||||
|
||||
@@ -1,214 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSocketClient, type VoteEvent } from '../composables/useSocketClient';
|
||||
import { VoteParticleSystem, type ProgramTarget } from '../pixi/VoteParticleSystem';
|
||||
import { useDisplayStore } from '../stores/display';
|
||||
import PostcardGrid from '../components/PostcardGrid.vue';
|
||||
import type { VotingProgram, AdminState } from '@gala/shared/types';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
|
||||
const router = useRouter();
|
||||
const { isConnected, onlineUsers, onVoteUpdate } = useSocketClient();
|
||||
const displayStore = useDisplayStore();
|
||||
|
||||
// Pixi canvas ref
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
let particleSystem: VoteParticleSystem | null = null;
|
||||
// 节目列表
|
||||
const programs = ref<VotingProgram[]>([]);
|
||||
const votingOpen = ref(false);
|
||||
const totalVotes = ref(0);
|
||||
|
||||
// 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 },
|
||||
]);
|
||||
// Grid ref for stamp animation targeting
|
||||
const gridRef = ref<InstanceType<typeof PostcardGrid> | null>(null);
|
||||
|
||||
// Program card refs for position tracking
|
||||
const programRefs = ref<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
// Unsubscribe function
|
||||
let unsubscribeVote: (() => void) | null = null;
|
||||
// 格式化投票总数
|
||||
const formattedVotes = computed(() => {
|
||||
return totalVotes.value.toLocaleString();
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
function setProgramRef(id: string, el: HTMLElement | null) {
|
||||
if (el) {
|
||||
programRefs.value.set(id, el);
|
||||
}
|
||||
// 处理状态同步
|
||||
function handleStateSync(state: AdminState) {
|
||||
programs.value = state.voting.programs;
|
||||
votingOpen.value = state.voting.subPhase === 'OPEN';
|
||||
totalVotes.value = state.voting.totalVotes;
|
||||
}
|
||||
|
||||
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);
|
||||
// 监听投票更新事件
|
||||
function handleVoteUpdate(data: { candidateId: string; totalVotes: number; stamp?: any }) {
|
||||
const program = programs.value.find(p => p.id === data.candidateId);
|
||||
if (program) {
|
||||
program.votes = event.totalVotes;
|
||||
}
|
||||
|
||||
// Spawn particle effect
|
||||
if (particleSystem) {
|
||||
particleSystem.spawnVoteParticle(event.candidateId);
|
||||
program.votes = data.totalVotes;
|
||||
// 如果有印章信息,添加到列表触发动画
|
||||
if (data.stamp) {
|
||||
if (!program.stamps) program.stamps = [];
|
||||
program.stamps.push(data.stamp);
|
||||
}
|
||||
// 更新总票数
|
||||
totalVotes.value = programs.value.reduce((sum, p) => sum + p.votes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
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);
|
||||
// 请求初始状态
|
||||
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 class="title">实时投票</h1>
|
||||
<div class="header-right">
|
||||
<div class="vote-counter">
|
||||
<span class="counter-label">总票数</span>
|
||||
<span class="counter-value">{{ formattedVotes }}</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>
|
||||
|
||||
<!-- 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 class="grid-container">
|
||||
<PostcardGrid
|
||||
ref="gridRef"
|
||||
:programs="programs"
|
||||
:columns="4"
|
||||
:rows="2"
|
||||
/>
|
||||
</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 *;
|
||||
// Philatelic postcard theme colors
|
||||
$color-paper: #FDFBF7;
|
||||
$color-ink: #2c2c2c;
|
||||
$color-red: #c41e3a;
|
||||
$color-gold: #d4af37;
|
||||
$color-text-muted: #666;
|
||||
|
||||
.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;
|
||||
background: $color-paper;
|
||||
color: $color-ink;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 50px;
|
||||
padding: 20px 40px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
z-index: 100;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom: 1px solid #ddd;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 1px solid $color-gold;
|
||||
color: $color-gold;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
border: 1px solid $color-ink;
|
||||
color: $color-ink;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all $transition-fast;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-gold, 0.1);
|
||||
background: $color-ink;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
font-size: 32px;
|
||||
font-family: 'SimSun', 'Songti SC', serif;
|
||||
font-weight: bold;
|
||||
color: $color-red;
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vote-counter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
background: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
|
||||
.counter-label {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.counter-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $color-red;
|
||||
line-height: 1;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
@@ -216,128 +193,92 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.online-count {
|
||||
.status-badge {
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: #f5f5f5;
|
||||
color: $color-text-muted;
|
||||
border: 1px solid #ddd;
|
||||
|
||||
&.open {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.online-count {
|
||||
font-size: 14px;
|
||||
color: $color-text-muted;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
transition: background 0.3s;
|
||||
background: #ccc;
|
||||
|
||||
&.connected {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 10px rgba(74, 222, 128, 0.5);
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 900px) {
|
||||
padding: 16px 20px;
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.vote-counter .counter-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 12px 16px;
|
||||
justify-content: center;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
letter-spacing: 2px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vote-counter {
|
||||
padding: 6px 12px;
|
||||
|
||||
.counter-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
.online-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.programs-grid {
|
||||
.grid-container {
|
||||
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);
|
||||
}
|
||||
}
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,8 +12,10 @@ import type {
|
||||
MusicControlPayload,
|
||||
LotteryRound,
|
||||
LotteryWinner,
|
||||
VotingProgram,
|
||||
VoteStamp,
|
||||
} from '@gala/shared/types';
|
||||
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG } from '@gala/shared/types';
|
||||
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG, DEFAULT_PROGRAMS } from '@gala/shared/types';
|
||||
|
||||
const ADMIN_STATE_KEY = 'gala:admin:state';
|
||||
|
||||
@@ -31,8 +33,29 @@ class AdminService {
|
||||
try {
|
||||
const savedState = await redis.get(ADMIN_STATE_KEY);
|
||||
if (savedState) {
|
||||
this.state = JSON.parse(savedState);
|
||||
logger.info('Admin state restored from Redis');
|
||||
const parsed = JSON.parse(savedState);
|
||||
// Deep merge to ensure new fields have defaults
|
||||
this.state = {
|
||||
...INITIAL_ADMIN_STATE,
|
||||
...parsed,
|
||||
voting: {
|
||||
...INITIAL_ADMIN_STATE.voting,
|
||||
...parsed.voting,
|
||||
// Ensure programs always has default values
|
||||
programs: parsed.voting?.programs?.length > 0
|
||||
? parsed.voting.programs
|
||||
: DEFAULT_PROGRAMS,
|
||||
},
|
||||
lottery: {
|
||||
...INITIAL_ADMIN_STATE.lottery,
|
||||
...parsed.lottery,
|
||||
},
|
||||
music: {
|
||||
...INITIAL_ADMIN_STATE.music,
|
||||
...parsed.music,
|
||||
},
|
||||
};
|
||||
logger.info('Admin state restored from Redis (merged with defaults)');
|
||||
} else {
|
||||
await this.saveState();
|
||||
logger.info('Admin state initialized with defaults');
|
||||
@@ -135,6 +158,169 @@ class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Program Voting Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start voting for a specific program by ID
|
||||
*/
|
||||
async startProgramVoting(programId: string): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const programs = this.state.voting.programs;
|
||||
const targetProgram = programs.find(p => p.id === programId);
|
||||
|
||||
if (!targetProgram) {
|
||||
return { success: false, message: 'Program not found' };
|
||||
}
|
||||
|
||||
// Mark previous current program as completed
|
||||
if (this.state.voting.currentProgramId) {
|
||||
const prevProgram = programs.find(p => p.id === this.state.voting.currentProgramId);
|
||||
if (prevProgram && prevProgram.status === 'voting') {
|
||||
prevProgram.status = 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
// Set new current program
|
||||
targetProgram.status = 'voting';
|
||||
this.state.voting.currentProgramId = programId;
|
||||
this.state.voting.currentProgramIndex = programs.indexOf(targetProgram);
|
||||
this.state.voting.votingStartedAt = Date.now();
|
||||
|
||||
await this.saveState();
|
||||
logger.info({ programId, programName: targetProgram.name }, 'Program voting started');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, programId }, 'Failed to start program voting');
|
||||
return { success: false, message: 'Failed to start program voting' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to next program in order
|
||||
*/
|
||||
async nextProgram(): Promise<{ success: boolean; message?: string; programId?: string }> {
|
||||
try {
|
||||
const programs = this.state.voting.programs;
|
||||
|
||||
// Find next pending program
|
||||
const nextProgram = programs.find(p => p.status === 'pending');
|
||||
|
||||
if (!nextProgram) {
|
||||
return { success: false, message: 'No more programs to vote' };
|
||||
}
|
||||
|
||||
const result = await this.startProgramVoting(nextProgram.id);
|
||||
if (result.success) {
|
||||
return { success: true, programId: nextProgram.id };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to move to next program');
|
||||
return { success: false, message: 'Failed to move to next program' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder programs (only pending ones can be reordered)
|
||||
*/
|
||||
async reorderPrograms(programIds: string[]): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const programs = this.state.voting.programs;
|
||||
|
||||
// Validate all IDs exist
|
||||
for (const id of programIds) {
|
||||
if (!programs.find(p => p.id === id)) {
|
||||
return { success: false, message: `Program ${id} not found` };
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder: completed/voting first, then pending in new order
|
||||
const completed = programs.filter(p => p.status === 'completed' || p.status === 'voting');
|
||||
const pending = programIds
|
||||
.filter(id => programs.find(p => p.id === id)?.status === 'pending')
|
||||
.map(id => programs.find(p => p.id === id)!);
|
||||
|
||||
this.state.voting.programs = [...completed, ...pending];
|
||||
|
||||
await this.saveState();
|
||||
logger.info({ newOrder: programIds }, 'Programs reordered');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to reorder programs');
|
||||
return { success: false, message: 'Failed to reorder programs' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle late catch (补投票) setting
|
||||
*/
|
||||
async toggleLateCatch(enabled: boolean): Promise<{ success: boolean }> {
|
||||
this.state.voting.allowLateCatch = enabled;
|
||||
await this.saveState();
|
||||
logger.info({ allowLateCatch: enabled }, 'Late catch setting changed');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a vote stamp to a program (for display on big screen)
|
||||
*/
|
||||
async addVoteStamp(programId: string, userName: string, department: string, ticketType: string): Promise<{ success: boolean; stamp?: VoteStamp }> {
|
||||
const program = this.state.voting.programs.find(p => p.id === programId);
|
||||
if (!program) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const stamp: VoteStamp = {
|
||||
id: `stamp_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
|
||||
userName,
|
||||
department,
|
||||
ticketType,
|
||||
x: 10 + Math.random() * 80, // Random X position (10-90%)
|
||||
y: 10 + Math.random() * 80, // Random Y position (10-90%)
|
||||
rotation: -30 + Math.random() * 60, // Random rotation (-30 to +30 degrees)
|
||||
timestamp: Date.now(),
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (!program.stamps) program.stamps = [];
|
||||
program.stamps.push(stamp);
|
||||
program.votes++;
|
||||
this.state.voting.totalVotes++;
|
||||
|
||||
await this.saveState();
|
||||
return { success: true, stamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can vote for a program
|
||||
*/
|
||||
canVoteForProgram(programId: string): { canVote: boolean; reason?: string } {
|
||||
const program = this.state.voting.programs.find(p => p.id === programId);
|
||||
if (!program) {
|
||||
return { canVote: false, reason: '节目不存在' };
|
||||
}
|
||||
|
||||
if (this.state.voting.subPhase !== 'OPEN') {
|
||||
return { canVote: false, reason: '投票通道未开放' };
|
||||
}
|
||||
|
||||
if (program.status === 'pending') {
|
||||
return { canVote: false, reason: '该节目投票尚未开始' };
|
||||
}
|
||||
|
||||
if (program.status === 'completed' && !this.state.voting.allowLateCatch) {
|
||||
return { canVote: false, reason: '该节目投票已结束' };
|
||||
}
|
||||
|
||||
// Can vote for 'voting' status, or 'completed' if allowLateCatch is true
|
||||
return { canVote: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Control lottery state machine
|
||||
*/
|
||||
@@ -222,7 +408,7 @@ class AdminService {
|
||||
private pickRandomWinners(): LotteryWinner[] {
|
||||
const prizeConfig = PRIZE_CONFIG.find(p => p.round === this.state.lottery.round);
|
||||
const count = prizeConfig?.winnerCount || 1;
|
||||
|
||||
|
||||
// Demo winners for testing
|
||||
const demoNames = [
|
||||
{ id: 'w1', name: '张明', department: '技术部' },
|
||||
@@ -236,7 +422,7 @@ class AdminService {
|
||||
{ id: 'w9', name: '郑雪', department: '技术部' },
|
||||
{ id: 'w10', name: '孙浩', department: '市场部' },
|
||||
];
|
||||
|
||||
|
||||
// Shuffle and pick
|
||||
const shuffled = [...demoNames].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count);
|
||||
@@ -256,8 +442,8 @@ class AdminService {
|
||||
try {
|
||||
if (scope === 'all' || scope === 'voting') {
|
||||
this.state.voting = {
|
||||
subPhase: 'CLOSED',
|
||||
totalVotes: 0,
|
||||
...INITIAL_ADMIN_STATE.voting,
|
||||
programs: DEFAULT_PROGRAMS.map(p => ({ ...p, votes: 0, stamps: [] })),
|
||||
};
|
||||
// Clear voting data in Redis
|
||||
await redis.del('gala:votes:*');
|
||||
|
||||
@@ -115,6 +115,19 @@ function handleConnection(socket: GalaSocket): void {
|
||||
handleAdminStateSync(socket);
|
||||
});
|
||||
|
||||
// Program voting control events
|
||||
socket.on('admin:start_program' as any, (data: { programId: string }, callback: AckCallback<void>) => {
|
||||
handleStartProgram(socket, data, callback);
|
||||
});
|
||||
|
||||
socket.on('admin:next_program' as any, (data: any, callback: AckCallback<void>) => {
|
||||
handleNextProgram(socket, callback);
|
||||
});
|
||||
|
||||
socket.on('admin:toggle_late_catch' as any, (data: { enabled: boolean }, callback: AckCallback<void>) => {
|
||||
handleToggleLateCatch(socket, data, callback);
|
||||
});
|
||||
|
||||
// Disconnect handler
|
||||
socket.on('disconnect', (reason) => {
|
||||
handleDisconnect(socket, reason);
|
||||
@@ -135,11 +148,12 @@ async function handleJoin(
|
||||
callback: AckCallback<ConnectionAckPayload>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { userId, userName, role } = data;
|
||||
const { userId, userName, role, department } = data;
|
||||
|
||||
// Store user data in socket
|
||||
socket.data.userId = userId;
|
||||
socket.data.userName = userName;
|
||||
socket.data.department = department || '未知部门';
|
||||
socket.data.role = role;
|
||||
socket.data.connectedAt = new Date();
|
||||
socket.data.sessionId = socket.id;
|
||||
@@ -235,12 +249,21 @@ async function handleVoteSubmit(
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast vote update to all clients
|
||||
// Add a vote stamp for the big screen display
|
||||
const stampResult = await adminService.addVoteStamp(
|
||||
data.candidateId,
|
||||
socket.data.userName || '匿名用户',
|
||||
socket.data.department || '未知部门',
|
||||
category
|
||||
);
|
||||
|
||||
// Broadcast vote update to all clients with stamp info
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
|
||||
candidateId: data.candidateId,
|
||||
category: category,
|
||||
totalVotes: result.new_count!,
|
||||
delta: 1,
|
||||
stamp: stampResult.stamp, // Pass the stamp info for animation
|
||||
});
|
||||
|
||||
callback({
|
||||
@@ -267,12 +290,21 @@ async function handleVoteSubmit(
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast vote update to all clients
|
||||
// Add a vote stamp for the big screen display (legacy category system)
|
||||
const stampResult = await adminService.addVoteStamp(
|
||||
data.candidateId,
|
||||
socket.data.userName || '匿名用户',
|
||||
socket.data.department || '未知部门',
|
||||
data.category as string
|
||||
);
|
||||
|
||||
// Broadcast vote update to all clients with stamp info
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
|
||||
candidateId: data.candidateId,
|
||||
category: data.category,
|
||||
totalVotes: result.new_count!,
|
||||
delta: 1,
|
||||
stamp: stampResult.stamp, // Include stamp for big screen
|
||||
});
|
||||
|
||||
callback({
|
||||
@@ -476,3 +508,76 @@ async function handleEmergencyReset(
|
||||
function handleAdminStateSync(socket: GalaSocket): void {
|
||||
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Program Voting Control Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle start program voting
|
||||
*/
|
||||
async function handleStartProgram(
|
||||
socket: GalaSocket,
|
||||
data: { programId: string },
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.startProgramVoting(data.programId);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle next program
|
||||
*/
|
||||
async function handleNextProgram(
|
||||
socket: GalaSocket,
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.nextProgram();
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle late catch voting
|
||||
*/
|
||||
async function handleToggleLateCatch(
|
||||
socket: GalaSocket,
|
||||
data: { enabled: boolean },
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.toggleLateCatch(data.enabled);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,36 @@ export interface VotingState {
|
||||
openedAt?: number;
|
||||
closedAt?: number;
|
||||
totalVotes: number;
|
||||
// 节目投票控制
|
||||
currentProgramId: string | null; // 当前投票节目 ID
|
||||
currentProgramIndex: number; // 当前节目序号 (0-based index)
|
||||
programs: VotingProgram[]; // 节目列表(已排序)
|
||||
allowLateCatch: boolean; // 补投票开关(默认 true)
|
||||
votingStartedAt?: number; // 当前节目投票开始时间(用于计时)
|
||||
}
|
||||
|
||||
export type ProgramVotingStatus = 'pending' | 'voting' | 'completed';
|
||||
|
||||
export interface VotingProgram {
|
||||
id: string;
|
||||
name: string;
|
||||
teamName: string;
|
||||
order: number; // 初始顺序
|
||||
status: ProgramVotingStatus; // 投票状态
|
||||
votes: number; // 票数
|
||||
stamps: VoteStamp[]; // 已盖的戳(用于大屏展示)
|
||||
}
|
||||
|
||||
export interface VoteStamp {
|
||||
id: string;
|
||||
userName: string;
|
||||
department: string;
|
||||
ticketType: string;
|
||||
x: number; // 随机 X 位置 (0-100%)
|
||||
y: number; // 随机 Y 位置 (0-100%)
|
||||
rotation: number; // 随机旋转角度
|
||||
timestamp: number;
|
||||
date: string; // 格式化日期 YYYY.MM.DD
|
||||
}
|
||||
|
||||
export interface LotteryState {
|
||||
@@ -116,6 +146,18 @@ export const PRIZE_CONFIG: PrizeConfig[] = [
|
||||
{ round: 4, level: '三等奖', name: '京东卡 500元', winnerCount: 10, zodiacFilter: 'horse' },
|
||||
];
|
||||
|
||||
// Default programs for voting
|
||||
export const DEFAULT_PROGRAMS: VotingProgram[] = [
|
||||
{ id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending', votes: 0, stamps: [] },
|
||||
{ id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending', votes: 0, stamps: [] },
|
||||
{ id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending', votes: 0, stamps: [] },
|
||||
{ id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending', votes: 0, stamps: [] },
|
||||
{ id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending', votes: 0, stamps: [] },
|
||||
{ id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending', votes: 0, stamps: [] },
|
||||
{ id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending', votes: 0, stamps: [] },
|
||||
{ id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending', votes: 0, stamps: [] },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Initial State
|
||||
// ============================================================================
|
||||
@@ -125,6 +167,10 @@ export const INITIAL_ADMIN_STATE: AdminState = {
|
||||
voting: {
|
||||
subPhase: 'CLOSED',
|
||||
totalVotes: 0,
|
||||
currentProgramId: null,
|
||||
currentProgramIndex: -1,
|
||||
programs: DEFAULT_PROGRAMS,
|
||||
allowLateCatch: true,
|
||||
},
|
||||
lottery: {
|
||||
round: 1,
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface DrawFilters {
|
||||
export interface JoinPayload {
|
||||
userId: string;
|
||||
userName: string;
|
||||
department?: string;
|
||||
role: UserRole;
|
||||
sessionToken?: string;
|
||||
}
|
||||
@@ -189,6 +190,7 @@ export interface InterServerEvents {
|
||||
export interface SocketData {
|
||||
userId: string;
|
||||
userName: string;
|
||||
department: string;
|
||||
role: UserRole;
|
||||
connectedAt: Date;
|
||||
sessionId: string;
|
||||
|
||||
Reference in New Issue
Block a user