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

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

467 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { 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>