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:
@@ -1,4 +1,4 @@
|
||||
@import './variables.scss';
|
||||
@use './variables.scss' as *;
|
||||
|
||||
// Reset
|
||||
*,
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
packages/client-mobile/src/components.d.ts
vendored
11
packages/client-mobile/src/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
192
packages/client-mobile/src/components/Postmark.vue
Normal file
192
packages/client-mobile/src/components/Postmark.vue
Normal 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>
|
||||
463
packages/client-mobile/src/components/ProgramCard.vue
Normal file
463
packages/client-mobile/src/components/ProgramCard.vue
Normal 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>
|
||||
295
packages/client-mobile/src/components/StampDock.vue
Normal file
295
packages/client-mobile/src/components/StampDock.vue
Normal 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>
|
||||
196
packages/client-mobile/src/components/VotingDock.vue
Normal file
196
packages/client-mobile/src/components/VotingDock.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
229
packages/client-mobile/src/stores/voting.ts
Normal file
229
packages/client-mobile/src/stores/voting.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
100
packages/client-mobile/src/views/VotingPage.vue
Normal file
100
packages/client-mobile/src/views/VotingPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user