修复投票计数与状态同步,完善票据与戳显示
修复投票系统:禁止重复投票、恢复状态、同步大屏 完善投票流程与展示:计数准确、状态可恢复、样式统一
This commit is contained in:
@@ -117,9 +117,9 @@ const currentDate = computed(() => {
|
||||
font-family: 'Kaiti', 'STKaiti', serif;
|
||||
font-weight: bold;
|
||||
|
||||
&.top { font-size: 10px; letter-spacing: 1px; }
|
||||
&.date { font-size: 8px; letter-spacing: 0.5px; opacity: 0.8; }
|
||||
&.bottom { font-size: 9px; }
|
||||
&.top { font-size: var(--postmark-top-size, 10px); letter-spacing: 1px; }
|
||||
&.date { font-size: var(--postmark-date-size, 8px); letter-spacing: 0.5px; opacity: 0.8; }
|
||||
&.bottom { font-size: var(--postmark-bottom-size, 9px); }
|
||||
}
|
||||
|
||||
.grunge-overlay {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useVotingStore, TICKET_INFO } from '../stores/voting';
|
||||
import { useVotingStore } from '../stores/voting';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import Postmark from './Postmark.vue';
|
||||
import stampImage from '../assets/images/stamp-horse-2026.png';
|
||||
import { GUOCHAO_ICONS } from '../utils/svgIcons';
|
||||
import type { TicketType } from '@gala/shared/constants';
|
||||
import type { VoteStamp } from '@gala/shared/types';
|
||||
import Postmark from './Postmark.vue';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
programName: string;
|
||||
teamName?: string;
|
||||
performer?: string;
|
||||
remark?: string;
|
||||
coverImage?: string;
|
||||
index?: number; // For stagger animation
|
||||
index?: number;
|
||||
status?: 'pending' | 'voting' | 'completed';
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
@@ -30,24 +29,20 @@ const connectionStore = useConnectionStore();
|
||||
// Animation states
|
||||
const isStamping = ref(false);
|
||||
const stampPhase = ref<'idle' | 'approach' | 'impact' | 'release'>('idle');
|
||||
const showInkMark = ref(false);
|
||||
|
||||
// Check if this card has a stamp
|
||||
const stampedWith = computed(() => votingStore.getProgramStamp(props.programId));
|
||||
const hasStamp = computed(() => stampedWith.value !== null);
|
||||
const stampInfo = computed(() => {
|
||||
if (!stampedWith.value) return null;
|
||||
return TICKET_INFO[stampedWith.value];
|
||||
});
|
||||
// 检查是否已为此节目投票(任何奖项)
|
||||
const votedAward = computed(() => votingStore.getProgramAward(props.programId));
|
||||
const hasVoted = computed(() => votedAward.value !== null);
|
||||
|
||||
// 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 && canVote.value;
|
||||
// 是否可以接收投票(需要已选中奖项且未投过)
|
||||
const canReceiveVote = computed(() => {
|
||||
return votingStore.isAwardSelected && canVote.value && !hasVoted.value;
|
||||
});
|
||||
|
||||
// Get voting status message
|
||||
@@ -59,12 +54,35 @@ const votingStatusLabel = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// 节目编号(邮编孔样式)
|
||||
const programNumber = computed(() => {
|
||||
const num = props.index + 1;
|
||||
return num.toString().padStart(2, '0');
|
||||
});
|
||||
|
||||
// From 显示:部门·表演者
|
||||
const fromDisplay = computed(() => {
|
||||
if (props.performer) {
|
||||
return `${props.teamName || ''}·${props.performer}`;
|
||||
}
|
||||
return props.teamName || 'The Performer';
|
||||
});
|
||||
|
||||
// 当前选中奖项的备注(用于移动端引导)
|
||||
const selectedAwardRemark = computed(() => {
|
||||
return votingStore.selectedAward?.remark || props.remark || 'With all our passion';
|
||||
});
|
||||
|
||||
// Stagger delay for entrance animation
|
||||
const entranceDelay = computed(() => `${props.index * 100}ms`);
|
||||
|
||||
async function handleCardClick() {
|
||||
if (!votingStore.isStampSelected) return;
|
||||
if (hasStamp.value) return;
|
||||
// 需要先选择奖项
|
||||
if (!votingStore.isAwardSelected) {
|
||||
return;
|
||||
}
|
||||
if (hasVoted.value) return;
|
||||
if (!canVote.value) return;
|
||||
|
||||
isStamping.value = true;
|
||||
|
||||
@@ -75,15 +93,14 @@ async function handleCardClick() {
|
||||
// Phase 2: Impact (100-150ms)
|
||||
stampPhase.value = 'impact';
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(40); // Sharp tick
|
||||
navigator.vibrate(40);
|
||||
}
|
||||
await delay(50);
|
||||
|
||||
// Phase 3: Release (150-300ms)
|
||||
stampPhase.value = 'release';
|
||||
showInkMark.value = true;
|
||||
|
||||
// Cast vote (optimistic UI)
|
||||
// Cast vote
|
||||
await votingStore.castVote(props.programId);
|
||||
|
||||
await delay(150);
|
||||
@@ -102,11 +119,11 @@ function delay(ms: number): Promise<void> {
|
||||
<div
|
||||
class="postcard"
|
||||
:class="{
|
||||
'has-stamp': hasStamp,
|
||||
'can-stamp': canReceiveStamp,
|
||||
'has-voted': hasVoted,
|
||||
'can-vote': canReceiveVote,
|
||||
'is-stamping': isStamping,
|
||||
'is-current': isCurrent,
|
||||
'is-disabled': !canVote && !hasStamp,
|
||||
'is-disabled': !canVote && !hasVoted,
|
||||
}"
|
||||
:style="{ '--entrance-delay': entranceDelay }"
|
||||
@click="handleCardClick"
|
||||
@@ -115,65 +132,60 @@ function delay(ms: number): Promise<void> {
|
||||
<div v-if="status !== 'pending'" class="status-badge" :class="status">
|
||||
{{ votingStatusLabel }}
|
||||
</div>
|
||||
|
||||
<!-- Paper Texture Background -->
|
||||
<div class="paper-texture">
|
||||
<!-- Left: Cover Image (Picture Side) -->
|
||||
<div class="postcard-image">
|
||||
<img v-if="coverImage" :src="coverImage" :alt="programName" />
|
||||
<div v-else class="image-placeholder">
|
||||
<span class="placeholder-text">{{ programName }}</span>
|
||||
<!-- Top Row: Zip codes left, Stamp right -->
|
||||
<div class="top-row">
|
||||
<div class="zip-codes">
|
||||
<div v-for="(code, idx) in ['2', '0', '2', '6', '0', (index + 1).toString()]" :key="idx" class="zip-box">
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stamp-box">
|
||||
<img :src="stampImage" alt="邮票" class="stamp-image" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Content Area (Address Side) -->
|
||||
<div class="postcard-content">
|
||||
<!-- Top: Program Info -->
|
||||
<div class="content-header">
|
||||
<h3 class="program-title">{{ programName }}</h3>
|
||||
<p v-if="teamName" class="team-name">{{ teamName }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Middle: Writing Area (Short Quote) -->
|
||||
<div class="writing-area">
|
||||
<p class="micro-copy handwritten">With all our passion</p>
|
||||
<span class="caption">倾情呈现</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Address Block -->
|
||||
<div class="address-block">
|
||||
<div class="address-row">
|
||||
<span class="label">From:</span>
|
||||
<span class="handwritten">{{ teamName || 'The Performer' }}</span>
|
||||
<div class="address-line"></div>
|
||||
</div>
|
||||
<div class="address-row">
|
||||
<span class="label">To:</span>
|
||||
<span class="handwritten">The 2026 Company Family</span>
|
||||
<div class="address-line"></div>
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content">
|
||||
<!-- Program Info (Title & Remark) -->
|
||||
<div class="program-info">
|
||||
<h2 class="program-name">{{ programName }}</h2>
|
||||
<div class="remark-box">
|
||||
<p class="remark-text">{{ selectedAwardRemark }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- Address Area (Bottom Right) -->
|
||||
<div class="address-area">
|
||||
<div class="address-line">
|
||||
<span class="label">寄:</span>
|
||||
<span class="handwritten">{{ fromDisplay }}</span>
|
||||
</div>
|
||||
<div class="address-line">
|
||||
<span class="label">收:</span>
|
||||
<span class="handwritten">2026 全体家人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voted Overlay -->
|
||||
<div v-if="hasVoted && votedAward" class="voted-overlay">
|
||||
<div class="voted-postmark-round">
|
||||
<Postmark
|
||||
v-if="hasStamp && stampInfo"
|
||||
:award-name="stampInfo.name"
|
||||
:award-icon-key="stampedWith!"
|
||||
:user-name="connectionStore.userName || ''"
|
||||
color="gold"
|
||||
class="applied-stamp"
|
||||
:award-name="votedAward.name"
|
||||
:award-icon-key="votedAward.id"
|
||||
:user-name="connectionStore.userName || '访客'"
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stamping Tool Overlay -->
|
||||
<!-- Stamping Animation Overlay -->
|
||||
<Transition name="stamp-tool">
|
||||
<div v-if="isStamping && votingStore.selectedStamp" class="stamp-tool-overlay">
|
||||
<div v-if="isStamping && votingStore.selectedAward" class="stamp-tool-overlay">
|
||||
<div
|
||||
class="stamp-tool"
|
||||
:class="[`phase-${stampPhase}`]"
|
||||
@@ -184,10 +196,9 @@ function delay(ms: number): Promise<void> {
|
||||
</div>
|
||||
<div class="tool-base">
|
||||
<div class="base-plate"></div>
|
||||
<div class="base-relief" v-html="GUOCHAO_ICONS[votingStore.selectedStamp as keyof typeof GUOCHAO_ICONS]"></div>
|
||||
<div class="base-relief">{{ votingStore.selectedAward.icon }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Impact Effects: Gold Dust / Particles -->
|
||||
<div v-if="stampPhase === 'impact' || stampPhase === 'release'" class="impact-effects">
|
||||
<div v-for="i in 12" :key="i" class="gold-dust"></div>
|
||||
</div>
|
||||
@@ -202,235 +213,189 @@ function delay(ms: number): Promise<void> {
|
||||
@use 'sass:color';
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
// Paper & Ink colors
|
||||
$paper-cream: #f8f4e8;
|
||||
$paper-lines: rgba(180, 160, 140, 0.3);
|
||||
$paper-cream: #FDFBF7;
|
||||
$ink-blue: #000080;
|
||||
$ink-red: #c21f30;
|
||||
$ink-charcoal: #333;
|
||||
$ink-red: #c41e3a;
|
||||
|
||||
.postcard {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: $paper-cream;
|
||||
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, opacity 0.3s ease;
|
||||
background-color: $paper-cream;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e0d8c0;
|
||||
|
||||
// Entrance animation
|
||||
animation: postcard-enter 0.5s ease-out backwards;
|
||||
animation-delay: var(--entrance-delay, 0ms);
|
||||
|
||||
&:active:not(.is-stamping) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&.can-stamp {
|
||||
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.5);
|
||||
animation: pulse-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.has-stamp {
|
||||
.paper-texture {
|
||||
background-color: color.adjust($paper-cream, $lightness: -2%);
|
||||
}
|
||||
&.can-vote {
|
||||
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.4);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
&.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 {
|
||||
display: flex;
|
||||
background-color: transparent;
|
||||
// Paper grain noise texture
|
||||
background-image: 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.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E");
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
// Left: Image area
|
||||
.postcard-image {
|
||||
flex: 0 0 40%;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-right: 1px dashed $paper-lines;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
min-height: 180px;
|
||||
background-image: 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.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 160px;
|
||||
background: linear-gradient(135deg, $color-primary 0%, color.adjust($color-primary, $lightness: -10%) 100%);
|
||||
/* Top Row: Zip + Stamp */
|
||||
.top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.zip-codes {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.zip-box {
|
||||
width: 20px;
|
||||
height: 24px;
|
||||
border: 1.5px solid $ink-red;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: bold;
|
||||
color: $color-gold;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// Right: Content area
|
||||
.postcard-content {
|
||||
flex: 1;
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.program-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: bold;
|
||||
color: #2a2a2a;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-bottom: $spacing-xs;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// Writing style
|
||||
.handwritten {
|
||||
font-family: 'Ma Shan Zheng', 'Kaiti', 'Brush Script MT', cursive;
|
||||
color: $ink-blue;
|
||||
display: inline-block;
|
||||
transform: rotate(-1deg);
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
.writing-area {
|
||||
margin-bottom: $spacing-sm;
|
||||
.micro-copy {
|
||||
font-size: $font-size-sm;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.caption {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.address-block {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
|
||||
.label {
|
||||
font-size: 10px;
|
||||
color: #444;
|
||||
font-weight: bold;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.handwritten {
|
||||
flex: 1;
|
||||
padding-left: 4px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.address-line {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 30px;
|
||||
right: 0;
|
||||
border-bottom: 1px dotted #ccc;
|
||||
}
|
||||
|
||||
// Stamp zone
|
||||
.stamp-zone {
|
||||
position: absolute;
|
||||
top: $spacing-sm;
|
||||
right: $spacing-sm;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
.stamp-box {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 1px solid #ddd;
|
||||
padding: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stamp-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 1px 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.applied-stamp {
|
||||
/* Main Content: Left Info, Right Address */
|
||||
.main-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.program-info {
|
||||
flex: 1;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.program-name {
|
||||
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: $ink-red;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.remark-box {
|
||||
border: 1px solid #dcdcdc;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
display: inline-block;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.remark-text {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.address-area {
|
||||
min-width: 130px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.address-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
border-bottom: 1px dotted #ccc;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.handwritten {
|
||||
font-family: 'Ma Shan Zheng', 'Kaiti', 'STKaiti', cursive;
|
||||
font-size: 13px;
|
||||
color: $ink-blue;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay for voted status */
|
||||
.voted-overlay {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: -10px;
|
||||
mix-blend-mode: multiply;
|
||||
transform: rotate(-8deg);
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// Stamp Tool Overlay
|
||||
.voted-postmark-round {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 52px;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.voted-postmark-round :deep(.postmark) {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
--postmark-top-size: 12px;
|
||||
--postmark-date-size: 10px;
|
||||
--postmark-bottom-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
/* Animation badge */
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 0 0 6px 6px;
|
||||
z-index: 20;
|
||||
|
||||
&.voting { background: $color-gold; color: #000; }
|
||||
&.completed { background: #666; color: #fff; }
|
||||
}
|
||||
|
||||
/* Stamp Tool - same as before but ensured it fits */
|
||||
.stamp-tool-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -453,22 +418,8 @@ $ink-charcoal: #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.4));
|
||||
|
||||
.handle-top {
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
background: #4a342e;
|
||||
border-radius: 50% / 100% 100% 0 0;
|
||||
}
|
||||
|
||||
.handle-body {
|
||||
width: 24px;
|
||||
height: 45px;
|
||||
background: linear-gradient(90deg, #5d4037 0%, #8d6e63 50%, #5d4037 100%);
|
||||
border-radius: 2px 2px 4px 4px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.handle-top { width: 16px; height: 8px; background: #4a342e; border-radius: 50% / 100% 100% 0 0; }
|
||||
.handle-body { width: 20px; height: 35px; background: linear-gradient(90deg, #5d4037 0%, #8d6e63 50%, #5d4037 100%); border-radius: 2px 2px 4px 4px; }
|
||||
}
|
||||
|
||||
.tool-base {
|
||||
@@ -476,118 +427,44 @@ $ink-charcoal: #333;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: -2px;
|
||||
|
||||
.base-plate {
|
||||
width: 54px;
|
||||
height: 12px;
|
||||
background: linear-gradient(90deg, #aa8a31 0%, #f0c239 50%, #aa8a31 100%);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.base-relief {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-top: -2px;
|
||||
background: $ink-red;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255,255,255,0.9);
|
||||
padding: 8px;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
|
||||
|
||||
:deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.base-plate { width: 44px; height: 10px; background: linear-gradient(90deg, #aa8a31 0%, #f0c239 50%, #aa8a31 100%); border-radius: 4px; }
|
||||
.base-relief { width: 40px; height: 40px; background: $ink-red; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #fff; }
|
||||
}
|
||||
|
||||
// Animation phases
|
||||
.phase-approach {
|
||||
transform: scale(1.4) translateY(-80px) rotate(-10deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.phase-approach { transform: scale(1.4) translateY(-80px) rotate(-10deg); opacity: 0.8; }
|
||||
.phase-impact { transform: scale(0.9) translateY(0) rotate(0); opacity: 1; }
|
||||
.phase-release { transform: scale(1.1) translateY(-100px) rotate(5deg); opacity: 0; }
|
||||
|
||||
.phase-impact {
|
||||
transform: scale(0.9) translateY(0) rotate(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.phase-release {
|
||||
transform: scale(1.1) translateY(-100px) rotate(5deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Impact Effects: Gold Dust
|
||||
.impact-effects {
|
||||
position: absolute;
|
||||
top: 90%;
|
||||
left: 50%;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 4px 15px rgba(0,0,0,0.1), 0 0 0 1px rgba($color-gold, 0.2); }
|
||||
50% { box-shadow: 0 4px 25px rgba($color-gold, 0.3), 0 0 0 3px rgba($color-gold, 0.4); }
|
||||
}
|
||||
|
||||
.gold-dust {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: $color-gold;
|
||||
border-radius: 50%;
|
||||
filter: blur(1px);
|
||||
filter: blur(0.5px);
|
||||
animation: dust-fly 0.6s ease-out forwards;
|
||||
|
||||
@for $i from 1 through 12 {
|
||||
&:nth-child(#{$i}) {
|
||||
$angle: $i * 30deg;
|
||||
$dist: 40px + random(40);
|
||||
$dist: 25px + random(25);
|
||||
--tx: #{math.cos($angle) * $dist};
|
||||
--ty: #{math.sin($angle) * $dist};
|
||||
animation-delay: random(50) * 1ms;
|
||||
animation-delay: random(40) * 1ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dust-fly {
|
||||
0% { transform: translate(0, 0) scale(1); opacity: 1; }
|
||||
0% { transform: translate(0, 0) scale(1.5); opacity: 1; }
|
||||
100% { transform: translate(var(--tx), var(--ty)) scale(0); opacity: 0; }
|
||||
}
|
||||
|
||||
// Keyframes
|
||||
@keyframes postcard-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 30px rgba($color-gold, 0.5), 0 0 0 3px rgba($color-gold, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Transition for stamp tool
|
||||
.stamp-tool-enter-active,
|
||||
.stamp-tool-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.stamp-tool-enter-from,
|
||||
.stamp-tool-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.stamp-tool-enter-active, .stamp-tool-leave-active { transition: opacity 0.2s; }
|
||||
.stamp-tool-enter-from, .stamp-tool-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useVotingStore, TICKET_INFO } from '../stores/voting';
|
||||
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
|
||||
|
||||
import { GUOCHAO_ICONS } from '../utils/svgIcons';
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [ticketType: TicketType];
|
||||
}>();
|
||||
import { useVotingStore } from '../stores/voting';
|
||||
|
||||
const votingStore = useVotingStore();
|
||||
|
||||
const medals = computed(() => {
|
||||
return TICKET_TYPES.map((type, index) => ({
|
||||
type,
|
||||
...TICKET_INFO[type],
|
||||
isUsed: votingStore.tickets[type] !== null,
|
||||
isSelected: votingStore.selectedStamp === type,
|
||||
// Fan layout angle
|
||||
angle: (index - 3) * 8,
|
||||
svg: GUOCHAO_ICONS[type as keyof typeof GUOCHAO_ICONS],
|
||||
// 奖项帧列表
|
||||
const awardFrames = computed(() => {
|
||||
return votingStore.awards.map((award, index) => ({
|
||||
id: award.id,
|
||||
name: award.name,
|
||||
icon: award.icon,
|
||||
order: index + 1,
|
||||
isUsed: votingStore.isAwardUsed(award.id),
|
||||
isSelected: votingStore.selectedAwardId === award.id,
|
||||
votedProgram: votingStore.tickets[award.id] || null,
|
||||
}));
|
||||
});
|
||||
|
||||
function handleMedalClick(type: TicketType) {
|
||||
if (votingStore.tickets[type] !== null) return;
|
||||
|
||||
if (votingStore.selectedStamp === type) {
|
||||
votingStore.deselectStamp();
|
||||
} else {
|
||||
votingStore.selectStamp(type);
|
||||
emit('select', type);
|
||||
// 点击帧
|
||||
function handleFrameClick(awardId: string) {
|
||||
if (votingStore.isAwardUsed(awardId)) {
|
||||
// 已使用的奖项,显示已投状态
|
||||
return;
|
||||
}
|
||||
|
||||
if (votingStore.selectedAwardId === awardId) {
|
||||
votingStore.deselectAward();
|
||||
} else {
|
||||
votingStore.selectAward(awardId);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取节目简称
|
||||
function getProgramShortName(programId: string | null): string {
|
||||
if (!programId) return '';
|
||||
const program = votingStore.programs.find(p => p.id === programId);
|
||||
return program ? program.name.slice(0, 2) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voting-dock">
|
||||
<div class="dock-tray">
|
||||
<div
|
||||
v-for="medal in medals"
|
||||
:key="medal.type"
|
||||
class="medal-slot"
|
||||
:class="{
|
||||
'is-used': medal.isUsed,
|
||||
'is-selected': medal.isSelected,
|
||||
}"
|
||||
:style="{ '--angle': `${medal.angle}deg` }"
|
||||
@click="handleMedalClick(medal.type)"
|
||||
>
|
||||
<div class="medal-icon">
|
||||
<div class="medal-face">
|
||||
<div class="medal-svg-wrapper" v-html="medal.svg"></div>
|
||||
</div>
|
||||
<span class="medal-label">{{ medal.name.slice(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 选中提示 -->
|
||||
<div v-if="votingStore.selectedAward" class="selection-hint">
|
||||
<span class="hint-icon">{{ votingStore.selectedAward.icon }}</span>
|
||||
<span class="hint-text">请点击节目投出「{{ votingStore.selectedAward.name }}」</span>
|
||||
</div>
|
||||
|
||||
<div v-if="votingStore.isStampSelected" class="dock-hint">
|
||||
<span class="hint-arrow">👆</span>
|
||||
<span>选择节目,盖章投票</span>
|
||||
<!-- 电影胶片条 -->
|
||||
<div class="film-strip">
|
||||
<!-- 上方齿孔 -->
|
||||
<div class="sprocket-row top">
|
||||
<div v-for="n in Math.max(awardFrames.length + 2, 8)" :key="'top-' + n" class="sprocket-hole"></div>
|
||||
</div>
|
||||
|
||||
<!-- 帧容器(可横向滑动) -->
|
||||
<div class="frames-container">
|
||||
<div class="frames-scroll">
|
||||
<div
|
||||
v-for="frame in awardFrames"
|
||||
:key="frame.id"
|
||||
class="film-frame"
|
||||
:class="{
|
||||
'is-used': frame.isUsed,
|
||||
'is-selected': frame.isSelected,
|
||||
}"
|
||||
@click="handleFrameClick(frame.id)"
|
||||
>
|
||||
<div class="frame-icon">{{ frame.icon }}</div>
|
||||
<div class="frame-name">{{ frame.name.slice(0, 4) }}</div>
|
||||
<div v-if="frame.isUsed" class="voted-program">
|
||||
已投出
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下方齿孔 -->
|
||||
<div class="sprocket-row bottom">
|
||||
<div v-for="n in Math.max(awardFrames.length + 2, 8)" :key="'bottom-' + n" class="sprocket-hole"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,143 +96,159 @@ function handleMedalClick(type: TicketType) {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.dock-tray {
|
||||
// 选中提示
|
||||
.selection-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
padding: $spacing-md $spacing-md $spacing-lg;
|
||||
// Red Glassmorphism
|
||||
background: $color-surface-glass;
|
||||
backdrop-filter: $backdrop-blur;
|
||||
-webkit-backdrop-filter: $backdrop-blur;
|
||||
border-top: 1px solid rgba($color-gold, 0.3);
|
||||
box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.3);
|
||||
border-radius: $radius-xl $radius-xl 0 0;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, $color-gold 0%, #b8860b 100%);
|
||||
color: #1a1a1a;
|
||||
padding: 8px 16px;
|
||||
margin: 0 16px 8px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
animation: pulse-hint 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.medal-slot {
|
||||
position: relative;
|
||||
transform: rotate(var(--angle)) translateY(0);
|
||||
transform-origin: bottom center;
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
.hint-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@keyframes pulse-hint {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 12px rgba($color-gold, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba($color-gold, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.film-strip {
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 50%, #1a1a1a 100%);
|
||||
border-top: 2px solid rgba($color-gold, 0.3);
|
||||
box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.5);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// 齿孔行
|
||||
.sprocket-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0 12px;
|
||||
height: 10px;
|
||||
background: #111;
|
||||
|
||||
&.top {
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
}
|
||||
|
||||
.sprocket-hole {
|
||||
width: 6px;
|
||||
height: 5px;
|
||||
background: #0a0a0a;
|
||||
border-radius: 1px;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
margin: 2.5px 0;
|
||||
}
|
||||
|
||||
// 帧容器
|
||||
.frames-container {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.frames-scroll {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
// 胶片帧(奖项)
|
||||
.film-frame {
|
||||
width: 64px;
|
||||
height: 72px;
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin: 0 -2px;
|
||||
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
transform: rotate(var(--angle)) translateY(-4px) scale(0.95);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
|
||||
// 已使用
|
||||
&.is-used {
|
||||
opacity: 0.2;
|
||||
filter: grayscale(1) brightness(0.5);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(135deg, #1a3a1a 0%, #0a2a0a 100%);
|
||||
border-color: #2a5a2a;
|
||||
opacity: 0.8;
|
||||
|
||||
.frame-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.frame-name {
|
||||
color: #4a8a4a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 选中状态
|
||||
&.is-selected {
|
||||
transform: rotate(0deg) translateY(-25px) scale(1.25);
|
||||
z-index: 10;
|
||||
|
||||
.medal-face {
|
||||
background: linear-gradient(135deg, $color-gold 0%, #fff 50%, $color-gold 100%);
|
||||
color: $color-primary;
|
||||
box-shadow:
|
||||
0 8px 24px rgba($color-gold, 0.6),
|
||||
0 0 0 2px rgba($color-gold, 0.4);
|
||||
animation: selected-glow 1.5s ease-in-out infinite;
|
||||
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
|
||||
border-color: $color-gold;
|
||||
box-shadow:
|
||||
0 0 12px rgba($color-gold, 0.5),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-4px);
|
||||
|
||||
.frame-name {
|
||||
color: $color-gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.medal-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
.frame-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.medal-face {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: linear-gradient(135deg, $color-primary-dark 0%, $color-primary 50%, $color-primary-dark 100%);
|
||||
color: $color-gold;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.2);
|
||||
border: 1.5px solid rgba($color-gold, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.medal-svg-wrapper {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes selected-glow {
|
||||
0%, 100% { filter: drop-shadow(0 0 5px rgba($color-gold, 0.5)); }
|
||||
50% { filter: drop-shadow(0 0 15px rgba($color-gold, 0.8)); }
|
||||
}
|
||||
|
||||
.medal-emoji {
|
||||
font-size: 24px;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.medal-label {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-gold;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
.frame-name {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 58px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dock-hint {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
background: rgba($color-gold, 0.9);
|
||||
color: $color-bg-primary;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: 600;
|
||||
animation: hint-bounce 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hint-arrow {
|
||||
animation: arrow-bounce 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
0%, 100% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%) translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arrow-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.voted-program {
|
||||
font-size: 9px;
|
||||
color: #4a8a4a;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -107,6 +107,9 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
|
||||
// Start heartbeat
|
||||
startHeartbeat();
|
||||
|
||||
// Request initial admin state to ensure UI is in sync
|
||||
socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', (reason) => {
|
||||
@@ -144,6 +147,12 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
if (data.userVotedCategories) {
|
||||
votedCategories.value = data.userVotedCategories;
|
||||
}
|
||||
if (data.userTickets) {
|
||||
import('./voting').then(({ useVotingStore }) => {
|
||||
const votingStore = useVotingStore();
|
||||
votingStore.syncFromServer(data.userTickets);
|
||||
});
|
||||
}
|
||||
});
|
||||
// Admin state sync - update voting state
|
||||
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
|
||||
@@ -157,6 +166,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
programs: state.voting.programs,
|
||||
currentProgramId: state.voting.currentProgramId,
|
||||
allowLateCatch: state.voting.allowLateCatch,
|
||||
awards: state.voting.awards,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -192,6 +202,14 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
if (response.data.votedCategories) {
|
||||
votedCategories.value = response.data.votedCategories;
|
||||
}
|
||||
if (response.data.userTickets) {
|
||||
import('./voting').then(({ useVotingStore }) => {
|
||||
const votingStore = useVotingStore();
|
||||
votingStore.syncFromServer(response.data.userTickets);
|
||||
});
|
||||
}
|
||||
// 连接成功后主动同步票据,避免刷新后丢失投票状态
|
||||
requestSync();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,49 +1,46 @@
|
||||
import { defineStore } from 'pinia';
|
||||
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 }> = {
|
||||
creative: { name: '最佳创意', icon: 'creative' },
|
||||
visual: { name: '最佳视觉', icon: 'visual' },
|
||||
atmosphere: { name: '最佳氛围', icon: 'atmosphere' },
|
||||
performance: { name: '最佳表演', icon: 'performance' },
|
||||
teamwork: { name: '最佳团队', icon: 'teamwork' },
|
||||
popularity: { name: '最受欢迎', icon: 'popularity' },
|
||||
potential: { name: '最具潜力', icon: 'potential' },
|
||||
};
|
||||
|
||||
interface PendingVote {
|
||||
ticketType: TicketType;
|
||||
programId: string;
|
||||
timestamp: number;
|
||||
// 奖项配置接口(从服务器同步)
|
||||
export interface AwardConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface VotingProgram {
|
||||
// 节目接口
|
||||
export interface VotingProgram {
|
||||
id: string;
|
||||
name: string;
|
||||
teamName: string;
|
||||
performer?: string;
|
||||
order: number;
|
||||
remark?: string;
|
||||
status: 'pending' | 'voting' | 'completed';
|
||||
votes: number;
|
||||
}
|
||||
|
||||
interface PendingVote {
|
||||
awardId: string;
|
||||
programId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const useVotingStore = defineStore('voting', () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
// State: ticketType -> programId (null if unused)
|
||||
const tickets = ref<Record<TicketType, string | null>>({
|
||||
creative: null,
|
||||
visual: null,
|
||||
atmosphere: null,
|
||||
performance: null,
|
||||
teamwork: null,
|
||||
popularity: null,
|
||||
potential: null,
|
||||
});
|
||||
// ============================================================================
|
||||
// 奖项票系统:每个奖项一张票,票数 = 奖项数 = 节目数
|
||||
// ============================================================================
|
||||
|
||||
// 奖项列表(从服务器同步)
|
||||
const awards = ref<AwardConfig[]>([]);
|
||||
|
||||
// 已投票记录:awardId -> programId
|
||||
const tickets = ref<Record<string, string | null>>({});
|
||||
|
||||
// Server-synced voting state
|
||||
const votingOpen = ref(false);
|
||||
@@ -52,8 +49,8 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
const currentProgramId = ref<string | null>(null);
|
||||
const allowLateCatch = ref(true);
|
||||
|
||||
// Currently selected stamp in dock
|
||||
const selectedStamp = ref<TicketType | null>(null);
|
||||
// 当前选中的奖项(准备投票)
|
||||
const selectedAwardId = ref<string | null>(null);
|
||||
|
||||
// Pending votes (optimistic updates waiting for server confirmation)
|
||||
const pendingVotes = ref<Map<string, PendingVote>>(new Map());
|
||||
@@ -61,39 +58,83 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
// Animation state for cards
|
||||
const stampingCard = ref<string | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Computed
|
||||
const usedTickets = computed(() => {
|
||||
return TICKET_TYPES.filter((t) => tickets.value[t] !== null);
|
||||
// ============================================================================
|
||||
|
||||
// 已使用的票数
|
||||
const usedTicketCount = computed(() => {
|
||||
return Object.values(tickets.value).filter(v => v !== null).length;
|
||||
});
|
||||
|
||||
const availableTickets = computed(() => {
|
||||
return TICKET_TYPES.filter((t) => tickets.value[t] === null);
|
||||
// 总票数 = 奖项数
|
||||
const totalTicketCount = computed(() => awards.value.length);
|
||||
|
||||
// 剩余票数
|
||||
const remainingTicketCount = computed(() =>
|
||||
Math.max(0, totalTicketCount.value - usedTicketCount.value)
|
||||
);
|
||||
|
||||
// 是否有选中奖项
|
||||
const isAwardSelected = computed(() => selectedAwardId.value !== null);
|
||||
|
||||
// 获取选中的奖项信息
|
||||
const selectedAward = computed(() => {
|
||||
if (!selectedAwardId.value) return null;
|
||||
return awards.value.find(a => a.id === selectedAwardId.value) || null;
|
||||
});
|
||||
|
||||
const isStampSelected = computed(() => selectedStamp.value !== null);
|
||||
// 已使用的奖项列表
|
||||
const usedAwards = computed(() => {
|
||||
return awards.value.filter(a => tickets.value[a.id] !== null && tickets.value[a.id] !== undefined);
|
||||
});
|
||||
|
||||
// Check if a program has received any stamp from this user
|
||||
function getProgramStamp(programId: string): TicketType | null {
|
||||
for (const ticketType of TICKET_TYPES) {
|
||||
if (tickets.value[ticketType] === programId) {
|
||||
return ticketType;
|
||||
// 可用的奖项列表
|
||||
const availableAwards = computed(() => {
|
||||
return awards.value.filter(a => tickets.value[a.id] === null || tickets.value[a.id] === undefined);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 奖项票操作
|
||||
// ============================================================================
|
||||
|
||||
// 初始化票据状态(当奖项列表更新时)
|
||||
function initTickets() {
|
||||
const newTickets: Record<string, string | null> = {};
|
||||
for (const award of awards.value) {
|
||||
newTickets[award.id] = tickets.value[award.id] ?? null;
|
||||
}
|
||||
tickets.value = newTickets;
|
||||
}
|
||||
|
||||
// 检查某个奖项是否已使用
|
||||
function isAwardUsed(awardId: string): boolean {
|
||||
return tickets.value[awardId] !== null && tickets.value[awardId] !== undefined;
|
||||
}
|
||||
|
||||
// 检查某个节目是否已获得任何奖项
|
||||
function getProgramAward(programId: string): AwardConfig | null {
|
||||
for (const award of awards.value) {
|
||||
if (tickets.value[award.id] === programId) {
|
||||
return award;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Select a stamp from the dock
|
||||
function selectStamp(ticketType: TicketType) {
|
||||
if (tickets.value[ticketType] !== null) {
|
||||
showToast({ message: '该印章已使用', position: 'bottom' });
|
||||
// 选中一个奖项
|
||||
function selectAward(awardId: string) {
|
||||
if (isAwardUsed(awardId)) {
|
||||
const award = awards.value.find(a => a.id === awardId);
|
||||
showToast({ message: `${award?.name || '该奖项'}已投出`, position: 'bottom' });
|
||||
return;
|
||||
}
|
||||
selectedStamp.value = ticketType;
|
||||
selectedAwardId.value = awardId;
|
||||
}
|
||||
|
||||
// Deselect stamp
|
||||
function deselectStamp() {
|
||||
selectedStamp.value = null;
|
||||
// 取消选中
|
||||
function deselectAward() {
|
||||
selectedAwardId.value = null;
|
||||
}
|
||||
|
||||
// Trigger haptic feedback
|
||||
@@ -103,39 +144,39 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Cast vote with optimistic update
|
||||
// 投票:将选中的奖项投给某个节目
|
||||
async function castVote(programId: string): Promise<boolean> {
|
||||
const ticketType = selectedStamp.value;
|
||||
if (!ticketType) {
|
||||
showToast({ message: '请先选择一个印章', position: 'bottom' });
|
||||
const awardId = selectedAwardId.value;
|
||||
if (!awardId) {
|
||||
showToast({ message: '请先选择一个奖项', position: 'bottom' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if already voted for this program
|
||||
const existingStamp = getProgramStamp(programId);
|
||||
if (existingStamp) {
|
||||
showToast({ message: '已为该节目投过票', position: 'bottom' });
|
||||
// 检查是否已为该节目投过票(任何奖项)
|
||||
const existingAward = getProgramAward(programId);
|
||||
if (existingAward) {
|
||||
showToast({ message: `已将${existingAward.name}投给该节目`, position: 'bottom' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const previousValue = tickets.value[ticketType];
|
||||
tickets.value[ticketType] = programId;
|
||||
const previousValue = tickets.value[awardId];
|
||||
tickets.value[awardId] = programId;
|
||||
stampingCard.value = programId;
|
||||
|
||||
// Trigger haptic
|
||||
triggerHaptic();
|
||||
|
||||
// Create pending vote record
|
||||
const voteKey = `${ticketType}:${programId}`;
|
||||
const voteKey = `${awardId}:${programId}`;
|
||||
pendingVotes.value.set(voteKey, {
|
||||
ticketType,
|
||||
awardId,
|
||||
programId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Clear selection
|
||||
selectedStamp.value = null;
|
||||
selectedAwardId.value = null;
|
||||
|
||||
// Call backend
|
||||
try {
|
||||
@@ -153,7 +194,7 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
|
||||
socket.emit('vote:submit' as any, {
|
||||
candidateId: programId,
|
||||
category: ticketType,
|
||||
category: awardId, // 奖项ID作为category
|
||||
clientTimestamp: Date.now(),
|
||||
localId,
|
||||
}, (response: any) => {
|
||||
@@ -163,7 +204,6 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// Use server message if available
|
||||
const errorMessage = result.message || '投票失败,请重试';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
@@ -174,7 +214,7 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
|
||||
} catch (error) {
|
||||
// Revert optimistic update
|
||||
tickets.value[ticketType] = previousValue;
|
||||
tickets.value[awardId] = previousValue;
|
||||
pendingVotes.value.delete(voteKey);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '投票失败,请重试';
|
||||
@@ -193,20 +233,20 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke a vote
|
||||
async function revokeVote(ticketType: TicketType): Promise<boolean> {
|
||||
const programId = tickets.value[ticketType];
|
||||
// 撤销投票
|
||||
async function revokeVote(awardId: string): Promise<boolean> {
|
||||
const programId = tickets.value[awardId];
|
||||
if (!programId) return false;
|
||||
|
||||
// Optimistic update
|
||||
tickets.value[ticketType] = null;
|
||||
tickets.value[awardId] = null;
|
||||
|
||||
try {
|
||||
const socket = connectionStore.getSocket();
|
||||
if (!socket) throw new Error('Not connected');
|
||||
|
||||
const result = await new Promise<{ success: boolean }>((resolve) => {
|
||||
socket.emit('vote:revoke' as any, { ticketType }, (response: any) => {
|
||||
socket.emit('vote:revoke' as any, { awardId }, (response: any) => {
|
||||
resolve(response);
|
||||
});
|
||||
setTimeout(() => resolve({ success: false }), 10000);
|
||||
@@ -217,17 +257,23 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
|
||||
} catch {
|
||||
// Revert
|
||||
tickets.value[ticketType] = programId;
|
||||
tickets.value[awardId] = programId;
|
||||
showToast({ message: '撤销失败', position: 'bottom', type: 'fail' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync state from server
|
||||
function syncFromServer(serverTickets: Record<TicketType, string | null>) {
|
||||
function syncFromServer(serverTickets: Record<string, string | null>) {
|
||||
tickets.value = { ...serverTickets };
|
||||
}
|
||||
|
||||
// Sync awards from server
|
||||
function syncAwards(serverAwards: AwardConfig[]) {
|
||||
awards.value = serverAwards;
|
||||
initTickets();
|
||||
}
|
||||
|
||||
// Sync voting state from AdminState
|
||||
function syncVotingState(state: {
|
||||
votingOpen?: boolean;
|
||||
@@ -235,26 +281,29 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
programs?: VotingProgram[];
|
||||
currentProgramId?: string | null;
|
||||
allowLateCatch?: boolean;
|
||||
awards?: AwardConfig[];
|
||||
}) {
|
||||
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;
|
||||
if (state.awards !== undefined) syncAwards(state.awards);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 检查是否已为该节目投过票
|
||||
const existingAward = getProgramAward(programId);
|
||||
if (existingAward) {
|
||||
return { allowed: false, reason: `已投${existingAward.name}` };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
@@ -264,10 +313,6 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
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
|
||||
@@ -286,20 +331,34 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
});
|
||||
|
||||
return {
|
||||
// 奖项票状态
|
||||
awards,
|
||||
tickets,
|
||||
selectedStamp,
|
||||
selectedAwardId,
|
||||
selectedAward,
|
||||
pendingVotes,
|
||||
stampingCard,
|
||||
usedTickets,
|
||||
availableTickets,
|
||||
isStampSelected,
|
||||
getProgramStamp,
|
||||
selectStamp,
|
||||
deselectStamp,
|
||||
|
||||
// Computed
|
||||
usedTicketCount,
|
||||
totalTicketCount,
|
||||
remainingTicketCount,
|
||||
isAwardSelected,
|
||||
usedAwards,
|
||||
availableAwards,
|
||||
|
||||
// 操作
|
||||
initTickets,
|
||||
isAwardUsed,
|
||||
getProgramAward,
|
||||
selectAward,
|
||||
deselectAward,
|
||||
castVote,
|
||||
revokeVote,
|
||||
syncFromServer,
|
||||
// New exports
|
||||
syncAwards,
|
||||
|
||||
// 投票状态
|
||||
votingOpen,
|
||||
votingPaused,
|
||||
programs,
|
||||
@@ -312,4 +371,3 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
handleVoteUpdate,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -16,14 +16,13 @@ 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 },
|
||||
{ id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '张三、李四', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间,适合怀旧、温情、经典的表演。', status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '王五、赵六', order: 2, remark: '强调节目留下了值得回味的\'声音\',可以是歌声、朗诵声,甚至是幽默的回响。适配唱、诵、幽默类节目。', status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p3', name: '春风得意', teamName: '人力资源部', performer: '刘七、陈八', order: 3, remark: '赞美节目引发了跨越时代的共鸣,无论是家国情怀、青春记忆还是职场幽默。适配有感染力、引发集体回忆的节目。', status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '周九、吴十', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境,画面感强。适配意境优美、故事性强或对唱情歌类节目。', status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p5', name: '马到成功', teamName: '运营部', performer: '郑十一、冯十二', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。适配活力四射、改编新颖、引领现场气氛的节目。', status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p6', name: '一马当先', teamName: '产品部', performer: '孙十三、杨十四', order: 6, remark: '强调节目的独特韵味与精心打磨,可以是深情的独唱,也可以是巧妙的改编,突出\'独特\'和\'匠心\'。', status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '何十五、林十六', order: 7, remark: '赞美节目与\'复古70-80\'主题高度契合,与时代精神同频共振。适配主题鲜明、情怀真挚的集体性节目。', status: 'pending' as const, votes: 0 },
|
||||
];
|
||||
}
|
||||
return votingStore.programs;
|
||||
@@ -80,10 +79,10 @@ onMounted(() => {
|
||||
<div class="progress-ring">
|
||||
<svg viewBox="0 0 36 36" class="circular-progress">
|
||||
<path class="circle-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path class="circle-progress" :stroke-dasharray="`${(votingStore.usedTickets.length / 7) * 100}, 100`"
|
||||
<path class="circle-progress" :stroke-dasharray="`${votingStore.totalTicketCount > 0 ? (votingStore.usedTicketCount / votingStore.totalTicketCount) * 100 : 0}, 100`"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<span class="progress-text">{{ votingStore.usedTickets.length }}/7</span>
|
||||
<span class="progress-text">{{ votingStore.usedTicketCount }}/{{ votingStore.totalTicketCount }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
@@ -96,6 +95,8 @@ onMounted(() => {
|
||||
:program-id="program.id"
|
||||
:program-name="program.name"
|
||||
:team-name="program.teamName"
|
||||
:performer="(program as any).performer"
|
||||
:remark="(program as any).remark"
|
||||
:index="index"
|
||||
:status="program.status"
|
||||
:is-current="program.id === votingStore.currentProgramId"
|
||||
|
||||
Reference in New Issue
Block a user