feat: enhance lottery system with participant import and prize config

- Fix ES module import issue in admin.service.ts (require -> import)
- Fix lottery reveal ghosting by hiding name particles on complete
- Add participant import from Excel with tag calculation
- Add prize configuration service with JSON persistence
- Constrain winners overlay to scroll area dimensions
- Fix macOS lsof syntax in stop script
- Add HorseRace view and renderer (WIP)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-23 12:20:45 +08:00
parent 35d77cbb22
commit a442d050e4
23 changed files with 2523 additions and 325 deletions

View File

@@ -1,17 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { GUOCHAO_ICONS } from '../utils/svgIcons';
interface Props {
awardName: string;
awardIcon?: string;
awardIconKey: string; // Changed from awardIcon
userName?: string;
color?: 'red' | 'gold';
}
const props = withDefaults(defineProps<Props>(), {
awardIcon: '🏅',
awardIconKey: 'creative',
userName: '',
color: 'red',
color: 'gold', // Changed default to gold for mobile "applied" state
});
// Random imperfections for realism
@@ -26,9 +28,11 @@ onMounted(() => {
});
const inkColor = computed(() => {
return props.color === 'gold' ? '#D4A84B' : '#C21F30';
return props.color === 'gold' ? '#F0C239' : '#C21F30';
});
const svgIcon = computed(() => GUOCHAO_ICONS[props.awardIconKey as keyof typeof GUOCHAO_ICONS] || '');
const currentDate = computed(() => {
const now = new Date();
return `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
@@ -40,8 +44,8 @@ const currentDate = computed(() => {
class="postmark"
:class="[`postmark--${color}`]"
:style="{
transform: `rotate(${rotation}deg)`,
opacity: inkOpacity,
'--rotation': `${rotation}deg`,
'--ink-opacity': inkOpacity,
}"
>
<!-- Simplified SVG with award name on top and nickname on bottom -->
@@ -50,14 +54,14 @@ const currentDate = computed(() => {
class="postmark-svg"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Double Ring Design (Mobile characteristic) -->
<circle cx="50" cy="50" r="48" fill="none" :stroke="inkColor" stroke-width="2" />
<circle cx="50" cy="50" r="42" fill="none" :stroke="inkColor" stroke-width="1" />
<!-- Double Ring Design -->
<circle cx="50" cy="50" r="46" fill="none" :stroke="inkColor" stroke-width="2" />
<circle cx="50" cy="50" r="40" fill="none" :stroke="inkColor" stroke-width="1.2" />
<!-- Text paths consistent with Big Screen -->
<!-- Text paths -->
<defs>
<path id="path-top" d="M 20,50 A 30,30 0 1,1 80,50" />
<path id="path-bottom" d="M 80,50 A 30,30 0 0,1 20,50" />
<path id="path-top" d="M 22,50 A 28,28 0 1,1 78,50" />
<path id="path-bottom" d="M 78,50 A 28,28 0 0,1 22,50" />
</defs>
<!-- Top Text: Award Name -->
@@ -67,8 +71,13 @@ const currentDate = computed(() => {
</textPath>
</text>
<!-- Middle Text: Date -->
<text x="50" y="50" class="postmark-text date" text-anchor="middle" dominant-baseline="central" :fill="inkColor">
<!-- Middle: SVG Icon -->
<g transform="translate(35, 35) scale(0.3)" :style="{ color: inkColor }">
<g v-html="svgIcon"></g>
</g>
<!-- Date: slightly below center -->
<text x="50" y="65" class="postmark-text date" text-anchor="middle" dominant-baseline="central" :fill="inkColor">
{{ currentDate }}
</text>
@@ -90,11 +99,11 @@ const currentDate = computed(() => {
.postmark {
position: relative;
width: 70px;
height: 70px;
width: 76px;
height: 76px;
// Multiply blend mode for realism
mix-blend-mode: multiply;
animation: stamp-reveal 0.3s ease-out forwards;
animation: stamp-reveal 0.4s cubic-bezier(0.17, 0.89, 0.32, 1.49) forwards;
transform-origin: center center;
}
@@ -108,15 +117,15 @@ const currentDate = computed(() => {
font-family: 'Kaiti', 'STKaiti', serif;
font-weight: bold;
&.top { font-size: 11px; }
&.date { font-size: 10px; letter-spacing: 0.5px; }
&.bottom { font-size: 10px; }
&.top { font-size: 10px; letter-spacing: 1px; }
&.date { font-size: 8px; letter-spacing: 0.5px; opacity: 0.8; }
&.bottom { font-size: 9px; }
}
.grunge-overlay {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.08'/%3E%3C/svg%3E");
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%;
@@ -125,15 +134,11 @@ const currentDate = computed(() => {
@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));
transform: scale(1.6) rotate(var(--rotation));
}
100% {
opacity: var(--ink-opacity, 0.9);
transform: scale(1) rotate(var(--rotation, 0deg));
opacity: var(--ink-opacity);
transform: scale(1) rotate(var(--rotation));
}
}
</style>

View File

@@ -4,6 +4,7 @@ import { useVotingStore, TICKET_INFO } from '../stores/voting';
import { useConnectionStore } from '../stores/connection';
import Postmark from './Postmark.vue';
import stampImage from '../assets/images/stamp-horse-2026.png';
import { GUOCHAO_ICONS } from '../utils/svgIcons';
import type { TicketType } from '@gala/shared/constants';
import type { VoteStamp } from '@gala/shared/types';
@@ -161,9 +162,9 @@ function delay(ms: number): Promise<void> {
<Postmark
v-if="hasStamp && stampInfo"
:award-name="stampInfo.name"
:award-icon="stampInfo.icon"
:award-icon-key="stampedWith"
:user-name="connectionStore.userName || ''"
color="red"
color="gold"
class="applied-stamp"
/>
</div>
@@ -177,9 +178,18 @@ function delay(ms: number): Promise<void> {
class="stamp-tool"
:class="[`phase-${stampPhase}`]"
>
<div class="tool-handle"></div>
<div class="tool-handle">
<div class="handle-top"></div>
<div class="handle-body"></div>
</div>
<div class="tool-base">
<span class="tool-icon">{{ TICKET_INFO[votingStore.selectedStamp].icon }}</span>
<div class="base-plate"></div>
<div class="base-relief" v-html="GUOCHAO_ICONS[votingStore.selectedStamp as keyof typeof GUOCHAO_ICONS]"></div>
</div>
<!-- Impact Effects: Gold Dust / Particles -->
<div v-if="stampPhase === 'impact' || stampPhase === 'release'" class="impact-effects">
<div v-for="i in 12" :key="i" class="gold-dust"></div>
</div>
</div>
</div>
@@ -188,6 +198,7 @@ function delay(ms: number): Promise<void> {
</template>
<style lang="scss" scoped>
@use 'sass:math';
@use 'sass:color';
@use '../assets/styles/variables.scss' as *;
@@ -204,6 +215,7 @@ $ink-charcoal: #333;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.3s ease;
background-color: $paper-cream;
// Entrance animation
animation: postcard-enter 0.5s ease-out backwards;
@@ -214,7 +226,7 @@ $ink-charcoal: #333;
}
&.can-stamp {
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px $color-gold;
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.5);
animation: pulse-glow 1.5s ease-in-out infinite;
}
@@ -264,7 +276,7 @@ $ink-charcoal: #333;
.paper-texture {
display: flex;
background-color: $paper-cream;
background-color: transparent;
// Paper grain noise texture
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E");
min-height: 160px;
@@ -406,6 +418,7 @@ $ink-charcoal: #333;
height: 60px;
object-fit: contain;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 3px rgba(0,0,0,0.1);
}
.applied-stamp {
@@ -417,32 +430,6 @@ $ink-charcoal: #333;
z-index: 2;
}
.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;
@@ -451,58 +438,127 @@ $ink-charcoal: #333;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
z-index: 100;
}
.stamp-tool {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.1s ease-out, opacity 0.15s;
transition: all 0.15s cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.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);
display: flex;
flex-direction: column;
align-items: center;
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.4));
.handle-top {
width: 20px;
height: 10px;
background: #4a342e;
border-radius: 50% / 100% 100% 0 0;
}
.handle-body {
width: 24px;
height: 45px;
background: linear-gradient(90deg, #5d4037 0%, #8d6e63 50%, #5d4037 100%);
border-radius: 2px 2px 4px 4px;
border: 1px solid rgba(255,255,255,0.1);
}
}
.tool-base {
width: 50px;
height: 50px;
background: $ink-red;
border-radius: 50%;
display: flex;
flex-direction: column;
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);
.base-plate {
width: 54px;
height: 12px;
background: linear-gradient(90deg, #aa8a31 0%, #f0c239 50%, #aa8a31 100%);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.base-relief {
width: 48px;
height: 48px;
margin-top: -2px;
background: $ink-red;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,0.9);
padding: 8px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
:deep(svg) {
width: 100%;
height: 100%;
}
}
}
// Animation phases
.phase-approach {
transform: scale(1.5) translateY(-30px);
transform: scale(1.4) translateY(-80px) rotate(-10deg);
opacity: 0.8;
}
.phase-impact {
transform: scale(0.95) translateY(0);
transform: scale(0.9) translateY(0) rotate(0);
opacity: 1;
}
.phase-release {
transform: scale(1) translateY(-50px);
transform: scale(1.1) translateY(-100px) rotate(5deg);
opacity: 0;
}
// Impact Effects: Gold Dust
.impact-effects {
position: absolute;
top: 90%;
left: 50%;
width: 100px;
height: 100px;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.gold-dust {
position: absolute;
width: 4px;
height: 4px;
background: $color-gold;
border-radius: 50%;
filter: blur(1px);
animation: dust-fly 0.6s ease-out forwards;
@for $i from 1 through 12 {
&:nth-child(#{$i}) {
$angle: $i * 30deg;
$dist: 40px + random(40);
--tx: #{math.cos($angle) * $dist};
--ty: #{math.sin($angle) * $dist};
animation-delay: random(50) * 1ms;
}
}
}
@keyframes dust-fly {
0% { transform: translate(0, 0) scale(1); opacity: 1; }
100% { transform: translate(var(--tx), var(--ty)) scale(0); opacity: 0; }
}
// Keyframes
@keyframes postcard-enter {
from {
@@ -517,17 +573,17 @@ $ink-charcoal: #333;
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px $color-gold;
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.3);
}
50% {
box-shadow: 0 4px 30px rgba($color-gold, 0.5), 0 0 0 3px $color-gold;
box-shadow: 0 4px 30px rgba($color-gold, 0.5), 0 0 0 3px rgba($color-gold, 0.5);
}
}
// Transition for stamp tool
.stamp-tool-enter-active,
.stamp-tool-leave-active {
transition: opacity 0.15s;
transition: opacity 0.2s;
}
.stamp-tool-enter-from,

View File

@@ -3,6 +3,8 @@ import { computed } from 'vue';
import { useVotingStore, TICKET_INFO } from '../stores/voting';
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
import { GUOCHAO_ICONS } from '../utils/svgIcons';
const emit = defineEmits<{
select: [ticketType: TicketType];
}>();
@@ -17,6 +19,7 @@ const medals = computed(() => {
isSelected: votingStore.selectedStamp === type,
// Fan layout angle
angle: (index - 3) * 8,
svg: GUOCHAO_ICONS[type as keyof typeof GUOCHAO_ICONS],
}));
});
@@ -48,7 +51,7 @@ function handleMedalClick(type: TicketType) {
>
<div class="medal-icon">
<div class="medal-face">
<span class="medal-emoji">🏅</span>
<div class="medal-svg-wrapper" v-html="medal.svg"></div>
</div>
<span class="medal-label">{{ medal.name.slice(2) }}</span>
</div>
@@ -57,7 +60,7 @@ function handleMedalClick(type: TicketType) {
<div v-if="votingStore.isStampSelected" class="dock-hint">
<span class="hint-arrow">👆</span>
<span>选择节目出牌</span>
<span>选择节目盖章投票</span>
</div>
</div>
</template>
@@ -78,39 +81,45 @@ function handleMedalClick(type: TicketType) {
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%
);
padding: $spacing-md $spacing-md $spacing-lg;
// Red Glassmorphism
background: $color-surface-glass;
backdrop-filter: $backdrop-blur;
-webkit-backdrop-filter: $backdrop-blur;
border-top: 1px solid rgba($color-gold, 0.3);
box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.3);
border-radius: $radius-xl $radius-xl 0 0;
}
.medal-slot {
position: relative;
transform: rotate(var(--angle)) translateY(0);
transform-origin: bottom center;
transition: transform 0.2s ease;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: pointer;
margin: 0 -4px;
margin: 0 -2px;
&:active {
transform: rotate(var(--angle)) translateY(-4px) scale(0.95);
}
&.is-used {
opacity: 0.3;
filter: grayscale(1);
opacity: 0.2;
filter: grayscale(1) brightness(0.5);
pointer-events: none;
}
&.is-selected {
transform: rotate(0deg) translateY(-20px) scale(1.15);
transform: rotate(0deg) translateY(-25px) scale(1.25);
z-index: 10;
.medal-icon {
filter: drop-shadow(0 8px 16px rgba($color-gold, 0.6));
.medal-face {
background: linear-gradient(135deg, $color-gold 0%, #fff 50%, $color-gold 100%);
color: $color-primary;
box-shadow:
0 8px 24px rgba($color-gold, 0.6),
0 0 0 2px rgba($color-gold, 0.4);
animation: selected-glow 1.5s ease-in-out infinite;
}
}
}
@@ -119,27 +128,41 @@ function handleMedalClick(type: TicketType) {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xs;
transition: filter 0.2s ease;
gap: $spacing-sm;
}
.medal-face {
width: 48px;
height: 48px;
background: linear-gradient(
135deg,
$color-gold 0%,
#ffd700 50%,
$color-gold 100%
);
width: 52px;
height: 52px;
background: linear-gradient(135deg, $color-primary-dark 0%, $color-primary 50%, $color-primary-dark 100%);
color: $color-gold;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4px 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);
0 4px 12px rgba(0, 0, 0, 0.4),
inset 0 1px 2px rgba(255, 255, 255, 0.2);
border: 1.5px solid rgba($color-gold, 0.4);
transition: all 0.3s ease;
}
.medal-svg-wrapper {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
:deep(svg) {
width: 100%;
height: 100%;
}
}
@keyframes selected-glow {
0%, 100% { filter: drop-shadow(0 0 5px rgba($color-gold, 0.5)); }
50% { filter: drop-shadow(0 0 15px rgba($color-gold, 0.8)); }
}
.medal-emoji {

View File

@@ -0,0 +1,64 @@
/**
* Guochao Style SVG Icons for the 7 Ticket Types
* Theme: Refined Lines, Gold on Red
*/
export const GUOCHAO_ICONS = {
// 最佳创意 - 笔架/毛笔 (Scholar's Brush)
creative: `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 80C30 80 40 75 50 75C60 75 70 80 70 80" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<path d="M50 20L50 65M50 65C50 65 45 70 50 75C55 70 50 65 50 65Z" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40 30L60 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
`,
// 最佳视觉 - 祥云扇面 (Folding Fan / Lucky Clouds)
visual: `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 70C20 70 35 40 50 40C65 40 80 70 80 70" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<path d="M50 70L50 85M50 85C40 80 30 75 20 70M50 85C60 80 70 75 80 70" stroke="currentColor" stroke-width="2"/>
<circle cx="40" cy="55" r="5" stroke="currentColor" stroke-width="2"/>
<circle cx="60" cy="55" r="5" stroke="currentColor" stroke-width="2"/>
</svg>
`,
// 最佳气氛 - 古典灯笼 (Classical Lantern)
atmosphere: `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="35" y="30" width="30" height="40" rx="5" stroke="currentColor" stroke-width="3"/>
<path d="M50 20L50 30M50 70L50 85" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<path d="M40 30C35 40 35 60 40 70M60 30C65 40 65 60 60 70" stroke="currentColor" stroke-width="2"/>
</svg>
`,
// 最佳表演 - 脸谱纹样 (Opera Mask)
performance: `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 40C30 30 70 30 70 40C70 65 50 80 50 80C50 80 30 65 30 40Z" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/>
<path d="M40 45H45M55 45H60" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<path d="M45 60C45 60 50 65 55 60" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
`,
// 最佳团队 - 同心结 (Endless Knot)
teamwork: `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="35" y="35" width="30" height="30" transform="rotate(45 50 50)" stroke="currentColor" stroke-width="3"/>
<path d="M50 25L50 35M50 65L50 75M25 50L35 50M65 50L75 50" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
</svg>
`,
// 最受欢迎 - 祥云爱心 (Cloud Heart)
popularity: `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50 75C50 75 25 60 25 40C25 30 35 25 45 30C47 31 49 33 50 35C51 33 53 31 55 30C65 25 75 30 75 40C75 60 50 75 50 75Z" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/>
<path d="M35 50C30 55 35 60 40 55" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
`,
// 最具潜力 - 锦鲤跃水 (Koi Fish / Rising Sun)
potential: `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50 30C60 30 70 45 70 60C70 75 60 85 50 85C40 85 30 75 30 60C30 45 40 30 50 30Z" stroke="currentColor" stroke-width="3"/>
<path d="M50 15V30" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<path d="M40 70L60 50" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
`,
};
export type GuochaoIconKey = keyof typeof GUOCHAO_ICONS;