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:
empty
2026-01-16 15:15:17 +08:00
parent 30cd29d45d
commit 84be8c4b5c
19 changed files with 2056 additions and 382 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,
};
});

View File

@@ -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"

View File

@@ -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>