feat: add Admin Control Panel, voting status check, and router security

Admin Control Panel:
- Add full AdminControl.vue with 3 sections (Voting, Lottery, Global)
- Add AdminLogin.vue with access code gate (20268888)
- Add admin.ts store with state persistence
- Add admin.types.ts with state machine types
- Add router guards for /admin/director-console

Voting System Fixes:
- Add voting status check before accepting votes (VOTING_CLOSED error)
- Fix client to display server error messages
- Fix button disabled logic to prevent ambiguity in paused state
- Auto-generate userId on connect to fix UNAUTHORIZED error

Big Screen Enhancements:
- Add LiveVotingView.vue with particle system
- Add LotteryMachine.ts with 3-stage animation (Galaxy/Storm/Reveal)
- Add useSocketClient.ts composable
- Fix MainDisplay.vue SCSS syntax error
- Add admin state sync listener in display store

Server Updates:
- Add admin.service.ts for state management
- Add isVotingOpen() and getVotingStatus() methods
- Add admin socket event handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-15 15:34:37 +08:00
parent e7397d22a9
commit 30cd29d45d
45 changed files with 7791 additions and 715 deletions

View File

@@ -1,4 +1,4 @@
@import './variables.scss';
@use './variables.scss' as *;
// Reset
*,

View File

@@ -1,13 +1,20 @@
// Guochao Red & Gold Theme Variables
// Year of the Horse Edition - 马年国潮主题
// Primary colors - 国潮红金配色
$color-primary: #c41230; // 中国红
// Primary colors - 丹砂红
$color-primary: #C21F30; // Dan-sha Red
$color-primary-light: #e8384f;
$color-primary-dark: #9a0e26;
$color-gold: #d4a84b; // 金色
$color-gold-light: #f0c96a;
$color-gold-dark: #b8923f;
// Gold colors - 香槟金
$color-gold: #F0C239; // Champagne Gold
$color-gold-light: #f5d76e;
$color-gold-dark: #d4a84b;
// Glassmorphism surfaces
$color-surface-glass: rgba(194, 31, 48, 0.85);
$color-surface-glass-light: rgba(194, 31, 48, 0.6);
$backdrop-blur: blur(12px);
// Background colors
$color-bg-primary: #fff5f5;

View File

@@ -8,7 +8,18 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ConnectionStatus: typeof import('./components/ConnectionStatus.vue')['default']
Postmark: typeof import('./components/Postmark.vue')['default']
ProgramCard: typeof import('./components/ProgramCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StampDock: typeof import('./components/StampDock.vue')['default']
VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanEmpty: typeof import('vant/es')['Empty']
VanField: typeof import('vant/es')['Field']
VanIcon: typeof import('vant/es')['Icon']
VanNavBar: typeof import('vant/es')['NavBar']
VotingDock: typeof import('./components/VotingDock.vue')['default']
}
}

View File

@@ -38,7 +38,7 @@ const statusText = computed(() => {
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
@use '../assets/styles/variables.scss' as *;
.connection-status {
display: flex;

View File

@@ -0,0 +1,192 @@
<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,
}"
>
<!-- Circular Date Stamp Design -->
<svg
viewBox="0 0 120 120"
class="postmark-svg"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Outer Ring -->
<circle
cx="60"
cy="60"
r="54"
fill="none"
:stroke="inkColor"
stroke-width="3"
/>
<!-- Inner Ring -->
<circle
cx="60"
cy="60"
r="46"
fill="none"
:stroke="inkColor"
stroke-width="1.5"
/>
<!-- Top Arc Text (Award Name) -->
<defs>
<path
id="topArc"
d="M 15,60 A 45,45 0 0,1 105,60"
fill="none"
/>
<path
id="bottomArc"
d="M 105,60 A 45,45 0 0,1 15,60"
fill="none"
/>
</defs>
<text
:fill="inkColor"
font-size="11"
font-weight="bold"
font-family="'Noto Serif SC', serif"
letter-spacing="2"
>
<textPath href="#topArc" startOffset="50%" text-anchor="middle">
{{ awardName }}
</textPath>
</text>
<!-- Bottom Arc Text (Date & User) -->
<text
:fill="inkColor"
font-size="8"
font-family="'Courier New', monospace"
>
<textPath href="#bottomArc" startOffset="50%" text-anchor="middle">
{{ currentDate }}{{ userName ? ` · ${userName}` : '' }}
</textPath>
</text>
<!-- Center Icon -->
<text
x="60"
y="68"
text-anchor="middle"
font-size="28"
class="center-icon"
>
{{ awardIcon }}
</text>
<!-- Decorative Stars -->
<text x="20" y="64" :fill="inkColor" font-size="8"></text>
<text x="95" y="64" :fill="inkColor" font-size="8"></text>
</svg>
<!-- Grunge Texture Overlay -->
<div class="grunge-overlay"></div>
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.postmark {
position: relative;
width: 80px;
height: 80px;
// CRITICAL: Multiply blend mode for ink absorption effect
mix-blend-mode: multiply;
animation: stamp-reveal 0.3s ease-out forwards;
transform-origin: center center;
}
.postmark-svg {
width: 100%;
height: 100%;
display: block;
}
.center-icon {
// Emoji doesn't take fill color, but we can adjust opacity
opacity: 0.9;
}
// Grunge texture for realistic ink imperfection
.grunge-overlay {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.12'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
pointer-events: none;
border-radius: 50%;
}
// Color variants
.postmark--red {
.postmark-svg {
filter: drop-shadow(0 0 1px rgba(194, 31, 48, 0.3));
}
}
.postmark--gold {
.postmark-svg {
filter: drop-shadow(0 0 1px rgba(212, 168, 75, 0.3));
}
}
@keyframes stamp-reveal {
0% {
opacity: 0;
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

@@ -0,0 +1,463 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVotingStore, TICKET_INFO } from '../stores/voting';
import { useConnectionStore } from '../stores/connection';
import Postmark from './Postmark.vue';
import type { TicketType } from '@gala/shared/constants';
interface Props {
programId: string;
programName: string;
teamName?: string;
coverImage?: string;
index?: number; // For stagger animation
}
const props = withDefaults(defineProps<Props>(), {
index: 0,
});
const votingStore = useVotingStore();
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];
});
// Can this card receive a stamp
const canReceiveStamp = computed(() => {
return votingStore.isStampSelected && !hasStamp.value;
});
// Stagger delay for entrance animation
const entranceDelay = computed(() => `${props.index * 100}ms`);
async function handleCardClick() {
if (!votingStore.isStampSelected) return;
if (hasStamp.value) return;
isStamping.value = true;
// Phase 1: Approach (0-100ms)
stampPhase.value = 'approach';
await delay(100);
// Phase 2: Impact (100-150ms)
stampPhase.value = 'impact';
if (navigator.vibrate) {
navigator.vibrate(40); // Sharp tick
}
await delay(50);
// Phase 3: Release (150-300ms)
stampPhase.value = 'release';
showInkMark.value = true;
// Cast vote (optimistic UI)
await votingStore.castVote(props.programId);
await delay(150);
// Reset
isStamping.value = false;
stampPhase.value = 'idle';
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
</script>
<template>
<div
class="postcard"
:class="{
'has-stamp': hasStamp,
'can-stamp': canReceiveStamp,
'is-stamping': isStamping,
}"
:style="{ '--entrance-delay': entranceDelay }"
@click="handleCardClick"
>
<!-- 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>
</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>
</div>
</div>
<!-- Stamp Area -->
<div class="stamp-zone">
<Postmark
v-if="hasStamp && stampInfo"
:award-name="stampInfo.name"
:award-icon="stampInfo.icon"
:user-name="connectionStore.userName || ''"
color="red"
class="applied-stamp"
/>
<div v-else class="stamp-placeholder">
<span class="stamp-label">贴票处</span>
<span class="placeholder-label">PLACE STAMP HERE</span>
</div>
</div>
</div>
</div>
<!-- Stamping Tool Overlay -->
<Transition name="stamp-tool">
<div v-if="isStamping && votingStore.selectedStamp" class="stamp-tool-overlay">
<div
class="stamp-tool"
:class="[`phase-${stampPhase}`]"
>
<div class="tool-handle"></div>
<div class="tool-base">
<span class="tool-icon">{{ TICKET_INFO[votingStore.selectedStamp].icon }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style lang="scss" scoped>
@use 'sass:color';
@use '../assets/styles/variables.scss' as *;
// Paper & Ink colors
$paper-cream: #f8f4e8;
$paper-lines: rgba(180, 160, 140, 0.3);
$ink-blue: #000080;
$ink-red: #c21f30;
$ink-charcoal: #333;
.postcard {
position: relative;
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;
// 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 $color-gold;
animation: pulse-glow 1.5s ease-in-out infinite;
}
&.has-stamp {
.paper-texture {
background-color: color.adjust($paper-cream, $lightness: -2%);
}
}
}
.paper-texture {
display: flex;
background-color: $paper-cream;
// 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%;
position: relative;
overflow: hidden;
border-right: 1px dashed $paper-lines;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-placeholder {
width: 100%;
height: 100%;
min-height: 160px;
background: linear-gradient(135deg, $color-primary 0%, color.adjust($color-primary, $lightness: -10%) 100%);
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;
}
// 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: center;
justify-content: center;
}
.applied-stamp {
mix-blend-mode: multiply;
transform: rotate(5deg);
}
.stamp-placeholder {
width: 60px;
height: 60px;
border: 1.5px dashed rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 4px;
}
.stamp-label {
font-size: 10px;
color: #ccc;
font-weight: bold;
margin-bottom: 2px;
}
.placeholder-label {
font-size: 6px;
color: #ddd;
font-family: 'Courier New', monospace;
letter-spacing: 0.5px;
}
// Stamp Tool Overlay
.stamp-tool-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
}
.stamp-tool {
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.1s ease-out, opacity 0.15s;
}
.tool-handle {
width: 24px;
height: 40px;
background: linear-gradient(180deg, #654321 0%, #8b4513 50%, #654321 100%);
border-radius: 4px 4px 2px 2px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.tool-base {
width: 50px;
height: 50px;
background: $ink-red;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-top: -2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
mix-blend-mode: multiply; // Blend with underlying paper/ink during impact
}
.tool-icon {
font-size: 24px;
filter: brightness(0) invert(1);
}
// Animation phases
.phase-approach {
transform: scale(1.5) translateY(-30px);
opacity: 0.8;
}
.phase-impact {
transform: scale(0.95) translateY(0);
opacity: 1;
}
.phase-release {
transform: scale(1) translateY(-50px);
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 $color-gold;
}
50% {
box-shadow: 0 4px 30px rgba($color-gold, 0.5), 0 0 0 3px $color-gold;
}
}
// Transition for stamp tool
.stamp-tool-enter-active,
.stamp-tool-leave-active {
transition: opacity 0.15s;
}
.stamp-tool-enter-from,
.stamp-tool-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,295 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useVotingStore, TICKET_INFO } from '../stores/voting';
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
const votingStore = useVotingStore();
const stamps = computed(() => {
return TICKET_TYPES.map((type) => ({
type,
...TICKET_INFO[type],
isUsed: votingStore.tickets[type] !== null,
isSelected: votingStore.selectedStamp === type,
}));
});
function handleStampClick(type: TicketType) {
if (votingStore.tickets[type] !== null) return; // Already used
if (votingStore.selectedStamp === type) {
votingStore.deselectStamp();
} else {
votingStore.selectStamp(type);
}
}
</script>
<template>
<div class="stamp-dock">
<!-- Wooden Tray -->
<div class="wooden-tray">
<!-- Tray Edge (Top) -->
<div class="tray-edge tray-edge-top"></div>
<!-- Stamp Handles -->
<div class="stamp-tray">
<div
v-for="stamp in stamps"
:key="stamp.type"
class="stamp-handle"
:class="{
'is-used': stamp.isUsed,
'is-selected': stamp.isSelected,
}"
@click="handleStampClick(stamp.type)"
>
<!-- Handle Body (Wood) -->
<div class="handle-body">
<div class="handle-grip"></div>
</div>
<!-- Rubber Base (Shows ink design) -->
<div class="rubber-base">
<span class="rubber-icon">{{ stamp.icon }}</span>
</div>
<!-- Label -->
<span class="stamp-label">{{ stamp.name }}</span>
</div>
</div>
<!-- Tray Edge (Bottom) -->
<div class="tray-edge tray-edge-bottom"></div>
</div>
<!-- Hint Text -->
<div v-if="votingStore.isStampSelected" class="dock-hint">
<span class="hint-icon">👆</span>
<span class="hint-text">点击节目卡片盖章</span>
</div>
</div>
</template>
<style lang="scss" scoped>
@use 'sass:color';
@use '../assets/styles/variables.scss' as *;
// Wood colors
$wood-light: #d4a574;
$wood-medium: #b8864e;
$wood-dark: #8b5a2b;
$wood-grain: #a0693a;
.stamp-dock {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: $z-index-fixed;
padding-bottom: env(safe-area-inset-bottom);
}
.wooden-tray {
background: linear-gradient(180deg, $wood-light 0%, $wood-medium 50%, $wood-dark 100%);
border-top: 3px solid $wood-dark;
position: relative;
// Wood grain texture
&::before {
content: '';
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
90deg,
transparent,
transparent 2px,
rgba($wood-grain, 0.1) 2px,
rgba($wood-grain, 0.1) 4px
);
pointer-events: none;
}
}
.tray-edge {
height: 8px;
background: linear-gradient(90deg, $wood-dark, $wood-medium, $wood-dark);
&.tray-edge-top {
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}
&.tray-edge-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
}
.stamp-tray {
display: flex;
justify-content: space-around;
padding: $spacing-sm $spacing-xs;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.stamp-handle {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
transition: transform 0.15s ease-out;
&:active:not(.is-used) {
transform: scale(0.95);
}
&.is-used {
opacity: 0.4;
cursor: not-allowed;
.handle-body {
filter: grayscale(0.5);
}
.rubber-base {
filter: grayscale(1);
}
}
&.is-selected {
transform: translateY(-10px);
.handle-body {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
}
.rubber-base {
box-shadow: 0 4px 12px rgba($color-primary, 0.5);
}
}
}
.handle-body {
width: 40px;
height: 28px;
background: linear-gradient(
180deg,
#654321 0%,
#8b4513 30%,
#a0522d 50%,
#8b4513 70%,
#654321 100%
);
border-radius: 6px 6px 2px 2px;
position: relative;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transition: box-shadow 0.15s, transform 0.15s;
}
.handle-grip {
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 4px;
background: linear-gradient(90deg, #5a3a1a, #7a5a3a, #5a3a1a);
border-radius: 2px;
&::after {
content: '';
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 3px;
background: linear-gradient(90deg, #5a3a1a, #7a5a3a, #5a3a1a);
border-radius: 2px;
}
}
.rubber-base {
width: 36px;
height: 36px;
background: $color-primary;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: box-shadow 0.15s;
// Rubber texture
&::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
circle at 30% 30%,
rgba(255, 255, 255, 0.1) 0%,
transparent 50%
);
border-radius: 4px;
pointer-events: none;
}
}
.rubber-icon {
font-size: 20px;
filter: brightness(0) invert(1); // White icon (reverse of stamp)
opacity: 0.9;
}
.stamp-label {
font-size: 10px;
color: #f5f0e6;
font-family: 'Courier New', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
white-space: nowrap;
max-width: 48px;
overflow: hidden;
text-overflow: ellipsis;
}
.dock-hint {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-xs;
padding: $spacing-xs;
background: rgba(0, 0, 0, 0.6);
animation: hint-pulse 1.5s ease-in-out infinite;
}
.hint-icon {
font-size: 14px;
animation: bounce 1s ease-in-out infinite;
}
.hint-text {
font-size: $font-size-sm;
color: $color-gold;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
@keyframes hint-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
</style>

View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useVotingStore, TICKET_INFO } from '../stores/voting';
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
const emit = defineEmits<{
select: [ticketType: TicketType];
}>();
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,
}));
});
function handleMedalClick(type: TicketType) {
if (votingStore.tickets[type] !== null) return;
if (votingStore.selectedStamp === type) {
votingStore.deselectStamp();
} else {
votingStore.selectStamp(type);
emit('select', type);
}
}
</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">
<span class="medal-emoji">🏅</span>
</div>
<span class="medal-label">{{ medal.name.slice(2) }}</span>
</div>
</div>
</div>
<div v-if="votingStore.isStampSelected" class="dock-hint">
<span class="hint-arrow">👆</span>
<span>选择节目出牌</span>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.voting-dock {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: $z-index-fixed;
padding-bottom: env(safe-area-inset-bottom);
}
.dock-tray {
display: flex;
justify-content: center;
align-items: flex-end;
padding: $spacing-sm $spacing-md $spacing-md;
background: linear-gradient(
to top,
rgba(26, 26, 26, 0.95) 0%,
rgba(26, 26, 26, 0.8) 70%,
transparent 100%
);
}
.medal-slot {
position: relative;
transform: rotate(var(--angle)) translateY(0);
transform-origin: bottom center;
transition: transform 0.2s ease;
cursor: pointer;
margin: 0 -4px;
&:active {
transform: rotate(var(--angle)) translateY(-4px) scale(0.95);
}
&.is-used {
opacity: 0.3;
filter: grayscale(1);
pointer-events: none;
}
&.is-selected {
transform: rotate(0deg) translateY(-20px) scale(1.15);
z-index: 10;
.medal-icon {
filter: drop-shadow(0 8px 16px rgba($color-gold, 0.6));
}
}
}
.medal-icon {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xs;
transition: filter 0.2s ease;
}
.medal-face {
width: 48px;
height: 48px;
background: linear-gradient(
135deg,
$color-gold 0%,
#ffd700 50%,
$color-gold 100%
);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.3),
inset 0 2px 4px rgba(255, 255, 255, 0.4);
border: 2px solid rgba(255, 255, 255, 0.3);
}
.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);
white-space: nowrap;
}
.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);
}
}
</style>

View File

@@ -13,11 +13,6 @@ const router = createRouter({
name: 'vote',
component: () => import('../views/VoteView.vue'),
},
{
path: '/vote/:category',
name: 'vote-category',
component: () => import('../views/VoteCategoryView.vue'),
},
{
path: '/result',
name: 'result',

View File

@@ -67,6 +67,12 @@ export const useConnectionStore = defineStore('connection', () => {
isConnecting.value = false;
reconnectAttempts.value = 0;
// Auto-generate userId if not set
if (!userId.value) {
userId.value = `user_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
userName.value = '访客';
}
// Join with user info
joinRoom();

View File

@@ -0,0 +1,229 @@
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';
// 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 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,
});
// Currently selected stamp in dock
const selectedStamp = ref<TicketType | null>(null);
// Pending votes (optimistic updates waiting for server confirmation)
const pendingVotes = ref<Map<string, PendingVote>>(new Map());
// Animation state for cards
const stampingCard = ref<string | null>(null);
// Computed
const usedTickets = computed(() => {
return TICKET_TYPES.filter((t) => tickets.value[t] !== null);
});
const availableTickets = computed(() => {
return TICKET_TYPES.filter((t) => tickets.value[t] === null);
});
const isStampSelected = computed(() => selectedStamp.value !== null);
// 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;
}
}
return null;
}
// Select a stamp from the dock
function selectStamp(ticketType: TicketType) {
if (tickets.value[ticketType] !== null) {
showToast({ message: '该印章已使用', position: 'bottom' });
return;
}
selectedStamp.value = ticketType;
}
// Deselect stamp
function deselectStamp() {
selectedStamp.value = null;
}
// Trigger haptic feedback
function triggerHaptic() {
if ('vibrate' in navigator) {
navigator.vibrate(50);
}
}
// Cast vote with optimistic update
async function castVote(programId: string): Promise<boolean> {
const ticketType = selectedStamp.value;
if (!ticketType) {
showToast({ message: '请先选择一个印章', position: 'bottom' });
return false;
}
// Check if already voted for this program
const existingStamp = getProgramStamp(programId);
if (existingStamp) {
showToast({ message: '已为该节目投过票', position: 'bottom' });
return false;
}
// Optimistic update
const previousValue = tickets.value[ticketType];
tickets.value[ticketType] = programId;
stampingCard.value = programId;
// Trigger haptic
triggerHaptic();
// Create pending vote record
const voteKey = `${ticketType}:${programId}`;
pendingVotes.value.set(voteKey, {
ticketType,
programId,
timestamp: Date.now(),
});
// Clear selection
selectedStamp.value = null;
// Call backend
try {
const socket = connectionStore.getSocket();
if (!socket) {
throw new Error('Not connected');
}
const localId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const result = await new Promise<{ success: boolean; error?: string; message?: string }>((resolve) => {
const timeoutId = setTimeout(() => {
resolve({ success: false, error: 'TIMEOUT', message: '网络超时,请重试' });
}, 10000);
socket.emit('vote:submit' as any, {
candidateId: programId,
category: ticketType,
clientTimestamp: Date.now(),
localId,
}, (response: any) => {
clearTimeout(timeoutId);
resolve(response);
});
});
if (!result.success) {
// Use server message if available
const errorMessage = result.message || '投票失败,请重试';
throw new Error(errorMessage);
}
// Success - remove from pending
pendingVotes.value.delete(voteKey);
return true;
} catch (error) {
// Revert optimistic update
tickets.value[ticketType] = previousValue;
pendingVotes.value.delete(voteKey);
const errorMessage = error instanceof Error ? error.message : '投票失败,请重试';
showToast({
message: errorMessage,
position: 'bottom',
type: 'fail',
});
return false;
} finally {
// Clear animation state after delay
setTimeout(() => {
stampingCard.value = null;
}, 600);
}
}
// Revoke a vote
async function revokeVote(ticketType: TicketType): Promise<boolean> {
const programId = tickets.value[ticketType];
if (!programId) return false;
// Optimistic update
tickets.value[ticketType] = 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) => {
resolve(response);
});
setTimeout(() => resolve({ success: false }), 10000);
});
if (!result.success) throw new Error('Revoke failed');
return true;
} catch {
// Revert
tickets.value[ticketType] = programId;
showToast({ message: '撤销失败', position: 'bottom', type: 'fail' });
return false;
}
}
// Sync state from server
function syncFromServer(serverTickets: Record<TicketType, string | null>) {
tickets.value = { ...serverTickets };
}
return {
tickets,
selectedStamp,
pendingVotes,
stampingCard,
usedTickets,
availableTickets,
isStampSelected,
getProgramStamp,
selectStamp,
deselectStamp,
castVote,
revokeVote,
syncFromServer,
};
});

View File

@@ -100,7 +100,7 @@ async function handleEnter() {
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
@use '../assets/styles/variables.scss' as *;
.home-view {
min-height: 100vh;

View File

@@ -1,227 +1,135 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted } from 'vue';
import { useVotingStore } from '../stores/voting';
import { useConnectionStore } from '../stores/connection';
import { VOTE_CATEGORIES } from '@gala/shared/types';
import type { VoteCategory } from '@gala/shared/types';
import VotingDock from '../components/VotingDock.vue';
import ProgramCard from '../components/ProgramCard.vue';
const router = useRouter();
const votingStore = useVotingStore();
const connectionStore = useConnectionStore();
// Category display info
const categoryInfo: Record<VoteCategory, { name: string; icon: string; color: string }> = {
best_employee: { name: '最佳员工', icon: 'star-o', color: '#c41230' },
best_team: { name: '最佳团队', icon: 'friends-o', color: '#d4a84b' },
best_newcomer: { name: '最佳新人', icon: 'fire-o', color: '#52c41a' },
best_innovation: { name: '最佳创新', icon: 'bulb-o', color: '#1890ff' },
best_service: { name: '最佳服务', icon: 'service-o', color: '#722ed1' },
best_collaboration: { name: '最佳协作', icon: 'cluster-o', color: '#fa8c16' },
best_leadership: { name: '最佳领导力', icon: 'medal-o', color: '#eb2f96' },
};
// 节目列表(从后端获取,这里先用 mock 数据)
const programs = ref([
{ id: 'p1', name: '龙腾四海', team: '市场部', coverImage: '' },
{ id: 'p2', name: '金马奔腾', team: '技术部', coverImage: '' },
{ id: 'p3', name: '春风得意', team: '人力资源部', coverImage: '' },
{ id: 'p4', name: '鸿运当头', team: '财务部', coverImage: '' },
{ id: 'p5', name: '马到成功', team: '运营部', coverImage: '' },
{ id: 'p6', name: '一马当先', team: '产品部', coverImage: '' },
{ id: 'p7', name: '万马奔腾', team: '设计部', coverImage: '' },
{ id: 'p8', name: '龙马精神', team: '销售部', coverImage: '' },
]);
const categories = computed(() => {
return VOTE_CATEGORIES.map((category) => ({
id: category,
...categoryInfo[category],
voted: connectionStore.votedCategories.includes(category),
}));
const isLoading = ref(false);
onMounted(() => {
if (!connectionStore.isConnected) {
connectionStore.connect();
}
});
const votedCount = computed(() => connectionStore.votedCategories.length);
const totalCategories = VOTE_CATEGORIES.length;
function goToCategory(category: VoteCategory) {
router.push(`/vote/${category}`);
}
function goToResults() {
router.push('/result');
}
</script>
<template>
<div class="vote-view safe-area-top safe-area-bottom">
<div class="vote-view">
<!-- Header -->
<div class="header">
<h1 class="title">投票评选</h1>
<div class="progress-info">
<span class="progress-text">已投 {{ votedCount }}/{{ totalCategories }}</span>
<van-progress
:percentage="(votedCount / totalCategories) * 100"
:show-pivot="false"
color="#c41230"
track-color="#f5f5f5"
stroke-width="6"
/>
<header class="page-header">
<h1 class="page-title">节目投票</h1>
<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`"
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>
</div>
</div>
</header>
<!-- Category list -->
<div class="category-list">
<div
v-for="category in categories"
:key="category.id"
class="category-card"
:class="{ voted: category.voted }"
@click="goToCategory(category.id)"
>
<div class="card-icon" :style="{ backgroundColor: category.color + '15' }">
<van-icon :name="category.icon" :color="category.color" size="28" />
</div>
<div class="card-content">
<h3 class="card-title">{{ category.name }}</h3>
<p class="card-status">
<template v-if="category.voted">
<van-icon name="success" color="#52c41a" />
<span class="voted-text">已投票</span>
</template>
<template v-else>
<span class="pending-text">待投票</span>
</template>
</p>
</div>
<van-icon name="arrow" class="card-arrow" color="#999" />
</div>
</div>
<!-- Program List -->
<main class="program-list">
<ProgramCard
v-for="program in programs"
:key="program.id"
:program-id="program.id"
:program-name="program.name"
:team-name="program.team"
:cover-image="program.coverImage"
/>
</main>
<!-- Bottom action -->
<div class="bottom-action">
<van-button
type="primary"
block
round
:disabled="votedCount === 0"
@click="goToResults"
>
查看投票结果
</van-button>
</div>
<!-- Voting Dock -->
<VotingDock />
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
@use '../assets/styles/variables.scss' as *;
.vote-view {
min-height: 100vh;
background: $color-bg-primary;
padding-bottom: 80px;
padding-bottom: 120px;
}
.header {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
color: $color-text-inverse;
.page-header {
background: $color-surface-glass;
backdrop-filter: $backdrop-blur;
-webkit-backdrop-filter: $backdrop-blur;
padding: $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + #{$spacing-lg});
.title {
font-size: $font-size-2xl;
font-weight: bold;
margin-bottom: $spacing-md;
}
.progress-info {
.progress-text {
display: block;
font-size: $font-size-sm;
margin-bottom: $spacing-xs;
opacity: 0.9;
}
:deep(.van-progress) {
background: rgba(255, 255, 255, 0.3);
.van-progress__portion {
background: $color-gold;
}
}
}
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: $z-index-sticky;
}
.category-list {
.page-title {
font-size: $font-size-2xl;
font-weight: bold;
color: $color-text-inverse;
}
.progress-ring {
position: relative;
width: 48px;
height: 48px;
}
.circular-progress {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.circle-bg {
fill: none;
stroke: rgba(255, 255, 255, 0.3);
stroke-width: 3;
}
.circle-progress {
fill: none;
stroke: $color-gold;
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dasharray 0.3s ease;
}
.progress-ring .progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: $font-size-xs;
font-weight: bold;
color: $color-text-inverse;
}
.program-list {
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.category-card {
display: flex;
align-items: center;
background: $color-bg-card;
border-radius: $radius-lg;
padding: $spacing-md;
box-shadow: $shadow-sm;
transition: all $transition-normal;
&:active {
transform: scale(0.98);
box-shadow: $shadow-md;
}
&.voted {
background: linear-gradient(135deg, #f6ffed 0%, #ffffff 100%);
border: 1px solid #b7eb8f;
}
.card-icon {
width: 52px;
height: 52px;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
margin-right: $spacing-md;
}
.card-content {
flex: 1;
.card-title {
font-size: $font-size-lg;
font-weight: 500;
color: $color-text-primary;
margin-bottom: $spacing-xs;
}
.card-status {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: $font-size-sm;
.voted-text {
color: $color-success;
}
.pending-text {
color: $color-text-muted;
}
}
}
.card-arrow {
margin-left: $spacing-sm;
}
}
.bottom-action {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $spacing-md;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$spacing-md});
background: $color-bg-card;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
:deep(.van-button--primary) {
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
border: none;
}
:deep(.van-button--disabled) {
opacity: 0.5;
}
gap: $spacing-lg;
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useVotingStore } from '../stores/voting';
import { useConnectionStore } from '../stores/connection';
import VotingDock from '../components/VotingDock.vue';
import ProgramCard from '../components/ProgramCard.vue';
import ConnectionStatus from '../components/ConnectionStatus.vue';
const votingStore = useVotingStore();
const connectionStore = useConnectionStore();
// Mock programs data (replace with API call)
const programs = ref([
{ id: 'p1', name: '龙腾四海', team: '市场部' },
{ id: 'p2', name: '金马奔腾', team: '技术部' },
{ id: 'p3', name: '春风得意', team: '人力资源部' },
{ id: 'p4', name: '鸿运当头', team: '财务部' },
{ id: 'p5', name: '马到成功', team: '运营部' },
{ id: 'p6', name: '一马当先', team: '产品部' },
{ id: 'p7', name: '万马奔腾', team: '设计部' },
{ id: 'p8', name: '龙马精神', team: '销售部' },
]);
const isLoading = ref(false);
onMounted(async () => {
// Connect if not connected
if (!connectionStore.isConnected) {
connectionStore.connect();
}
});
</script>
<template>
<div class="voting-page safe-area-top">
<!-- Header -->
<header class="page-header">
<h1 class="page-title">节目投票</h1>
<p class="page-subtitle">
已使用 {{ votingStore.usedTickets.length }}/7 枚印章
</p>
<ConnectionStatus />
</header>
<!-- Program List (Postcards fade in with stagger) -->
<main class="program-list">
<ProgramCard
v-for="(program, index) in programs"
:key="program.id"
:program-id="program.id"
:program-name="program.name"
:team-name="program.team"
:index="index"
/>
</main>
<!-- Stamp Dock -->
<VotingDock />
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.voting-page {
min-height: 100vh;
background: $color-bg-primary;
padding-bottom: 120px; // Space for dock
}
.page-header {
background: $color-surface-glass;
backdrop-filter: $backdrop-blur;
-webkit-backdrop-filter: $backdrop-blur;
padding: $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + #{$spacing-lg});
color: $color-text-inverse;
position: sticky;
top: 0;
z-index: $z-index-sticky;
}
.page-title {
font-size: $font-size-2xl;
font-weight: bold;
margin-bottom: $spacing-xs;
}
.page-subtitle {
font-size: $font-size-sm;
opacity: 0.9;
}
.program-list {
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
</style>