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

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

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

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

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

View File

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

View File

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

View File

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