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

@@ -5,6 +5,8 @@
"packageManager": "pnpm@9.15.0",
"type": "module",
"scripts": {
"start": "pnpm -r --parallel run dev",
"stop": "for port in 3000 5173 5174; do lsof -ti :$port | xargs kill -9 2>/dev/null; done || true",
"dev": "pnpm -r --parallel run dev",
"dev:mobile": "pnpm --filter @gala/client-mobile dev",
"dev:screen": "pnpm --filter @gala/client-screen dev",
@@ -31,4 +33,4 @@
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
}
}
}

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;

View File

@@ -3,7 +3,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
import PostcardItem from './PostcardItem.vue';
import type { VotingProgram } from '@gala/shared/types';
interface Props {
export interface Props {
programs: VotingProgram[];
columns?: number;
rows?: number;

View File

@@ -15,7 +15,7 @@ const TICKET_TYPE_NAMES: Record<string, string> = {
potential: '最具潜力',
};
interface Props {
export interface Props {
id: string;
name: string;
teamName: string;

View File

@@ -7,6 +7,7 @@ import type {
DrawSpinPayload,
DrawWinnerPayload,
VoteUpdatePayload,
AdminState,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
@@ -42,6 +43,7 @@ export interface VoteEvent {
export type VoteUpdateCallback = (event: VoteEvent) => void;
export type LotteryStateCallback = (state: LotteryState) => void;
export type AdminStateCallback = (state: AdminState) => void;
// ============================================================================
// Composable
@@ -68,6 +70,10 @@ export function useSocketClient() {
// Event callbacks
const voteCallbacks = new Set<VoteUpdateCallback>();
const lotteryCallbacks = new Set<LotteryStateCallback>();
const adminCallbacks = new Set<AdminStateCallback>();
// Admin state (from server)
const adminState = ref<AdminState | null>(null);
// Heartbeat
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
@@ -142,6 +148,16 @@ export function useSocketClient() {
voteCallbacks.forEach(cb => cb(event));
});
// Admin state sync (from admin control panel)
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
adminState.value = state;
// Notify all registered callbacks
adminCallbacks.forEach(cb => cb(state));
});
// Request initial admin state
socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
// Lottery events
socketInstance.on('draw:started' as any, (data: DrawStartPayload) => {
lotteryState.value = {
@@ -226,6 +242,11 @@ export function useSocketClient() {
return () => lotteryCallbacks.delete(callback);
}
function onAdminStateChange(callback: AdminStateCallback): () => void {
adminCallbacks.add(callback);
return () => adminCallbacks.delete(callback);
}
function notifyLotteryCallbacks(): void {
lotteryCallbacks.forEach(cb => cb(lotteryState.value));
}
@@ -268,12 +289,14 @@ export function useSocketClient() {
onlineUsers: readonly(onlineUsers),
latency: readonly(latency),
lotteryState: readonly(lotteryState),
adminState: readonly(adminState),
// Methods
connect,
disconnect,
onVoteUpdate,
onLotteryStateChange,
onAdminStateChange,
startLottery,
stopLottery,

View File

@@ -0,0 +1,382 @@
import { Application, Container, Graphics, Text, TextStyle, Ticker } from 'pixi.js';
// ============================================================================
// Constants
// ============================================================================
const COLORS = {
gold: 0xf0c239,
goldLight: 0xffd700,
goldDark: 0xd4a84b,
red: 0xc21f30,
trackLine: 0x333333,
trackBg: 0x1a1a1a,
textMuted: 0x888888,
};
const Easing = {
easeOutQuart: (t: number): number => 1 - Math.pow(1 - t, 4),
easeInOutCubic: (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
};
// ============================================================================
// Types
// ============================================================================
export interface RaceHorse {
id: string;
name: string;
votes: number;
trackIndex: number;
// Animation state
currentX: number;
targetX: number;
glowIntensity: number;
targetGlowIntensity: number;
particles: Array<{ x: number; y: number; alpha: number; speed: number }>;
}
export interface HorseRaceConfig {
trackCount: number;
trackHeight: number;
trackPadding: number;
minX: number;
maxX: number;
horseSize: number;
}
// ============================================================================
// HorseRaceRenderer Class
// ============================================================================
export class HorseRaceRenderer {
private app: Application;
private container: Container;
private trackLayer: Graphics;
private horseLayer: Graphics;
private labelLayer: Container;
private horses: Map<string, RaceHorse> = new Map();
private maxVotes: number = 1;
private config: HorseRaceConfig;
private isDestroyed = false;
private time = 0;
constructor() {
this.app = new Application();
this.container = new Container();
this.trackLayer = new Graphics();
this.horseLayer = new Graphics();
this.labelLayer = new Container();
this.config = {
trackCount: 8,
trackHeight: 80,
trackPadding: 20,
minX: 150,
maxX: 0, // Will be set on init
horseSize: 20,
};
}
async init(canvas: HTMLCanvasElement): Promise<void> {
await this.app.init({
canvas,
width: window.innerWidth,
height: window.innerHeight,
background: COLORS.trackBg,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
this.config.maxX = this.app.screen.width - 100;
this.container.addChild(this.trackLayer);
this.container.addChild(this.horseLayer);
this.container.addChild(this.labelLayer);
this.app.stage.addChild(this.container);
this.app.ticker.add(this.update.bind(this));
window.addEventListener('resize', this.handleResize.bind(this));
this.drawTracks();
}
// ============================================================================
// Public API
// ============================================================================
/**
* Set the list of programs (horses) to display
*/
setPrograms(programs: Array<{ id: string; name: string; votes: number }>): void {
// Update max votes
this.maxVotes = Math.max(1, ...programs.map((p) => p.votes));
programs.forEach((program, index) => {
const trackIndex = index % this.config.trackCount;
const trackY = this.getTrackY(trackIndex);
if (this.horses.has(program.id)) {
// Update existing horse
const horse = this.horses.get(program.id)!;
horse.votes = program.votes;
horse.targetX = this.calculateHorseX(program.votes);
horse.targetGlowIntensity = this.calculateGlowIntensity(program.votes);
} else {
// Create new horse
const startX = this.config.minX;
const horse: RaceHorse = {
id: program.id,
name: program.name,
votes: program.votes,
trackIndex,
currentX: startX,
targetX: this.calculateHorseX(program.votes),
glowIntensity: 0,
targetGlowIntensity: this.calculateGlowIntensity(program.votes),
particles: [],
};
this.horses.set(program.id, horse);
// Create label
this.createHorseLabel(horse, trackY);
}
});
this.drawTracks();
}
/**
* Update a single program's votes
*/
updateVotes(programId: string, newVotes: number): void {
const horse = this.horses.get(programId);
if (!horse) return;
horse.votes = newVotes;
// Recalculate max votes
this.maxVotes = Math.max(1, ...Array.from(this.horses.values()).map((h) => h.votes));
// Update all horses' target positions
this.horses.forEach((h) => {
h.targetX = this.calculateHorseX(h.votes);
h.targetGlowIntensity = this.calculateGlowIntensity(h.votes);
});
// Add burst particles for the updated horse
this.addParticleBurst(horse);
}
// ============================================================================
// Calculation Helpers
// ============================================================================
private getTrackY(trackIndex: number): number {
const totalHeight = this.config.trackCount * this.config.trackHeight;
const startY = (this.app.screen.height - totalHeight) / 2;
return startY + trackIndex * this.config.trackHeight + this.config.trackHeight / 2;
}
private calculateHorseX(votes: number): number {
const progress = this.maxVotes > 0 ? votes / this.maxVotes : 0;
return this.config.minX + (this.config.maxX - this.config.minX) * progress;
}
private calculateGlowIntensity(votes: number): number {
const progress = this.maxVotes > 0 ? votes / this.maxVotes : 0;
return 0.3 + progress * 0.7;
}
// ============================================================================
// Drawing
// ============================================================================
private drawTracks(): void {
this.trackLayer.clear();
for (let i = 0; i < this.config.trackCount; i++) {
const y = this.getTrackY(i);
// Track background stripe (alternating)
const stripeAlpha = i % 2 === 0 ? 0.05 : 0.02;
this.trackLayer.rect(
0,
y - this.config.trackHeight / 2,
this.app.screen.width,
this.config.trackHeight
);
this.trackLayer.fill({ color: 0xffffff, alpha: stripeAlpha });
// Track line
this.trackLayer.moveTo(this.config.minX - 20, y);
this.trackLayer.lineTo(this.config.maxX + 50, y);
this.trackLayer.stroke({ color: COLORS.trackLine, alpha: 0.3, width: 1 });
// Start line
this.trackLayer.moveTo(this.config.minX, y - 20);
this.trackLayer.lineTo(this.config.minX, y + 20);
this.trackLayer.stroke({ color: COLORS.gold, alpha: 0.5, width: 2 });
}
// Finish line
const finishX = this.config.maxX;
this.trackLayer.moveTo(finishX, this.getTrackY(0) - this.config.trackHeight / 2);
this.trackLayer.lineTo(finishX, this.getTrackY(this.config.trackCount - 1) + this.config.trackHeight / 2);
this.trackLayer.stroke({ color: COLORS.gold, alpha: 0.8, width: 3 });
// Finish checkered pattern
const checkerSize = 10;
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 3; col++) {
if ((row + col) % 2 === 0) {
this.trackLayer.rect(
finishX + col * checkerSize,
this.getTrackY(0) - this.config.trackHeight / 2 + row * checkerSize,
checkerSize,
checkerSize
);
this.trackLayer.fill({ color: COLORS.gold, alpha: 0.6 });
}
}
}
}
private createHorseLabel(horse: RaceHorse, y: number): void {
const style = new TextStyle({
fontFamily: 'SimSun, Songti SC, serif',
fontSize: 16,
fill: COLORS.textMuted,
align: 'right',
});
const label = new Text({ text: horse.name, style });
label.anchor.set(1, 0.5);
label.x = this.config.minX - 30;
label.y = y;
label.name = `label-${horse.id}`;
this.labelLayer.addChild(label);
}
private drawHorse(horse: RaceHorse): void {
const y = this.getTrackY(horse.trackIndex);
const x = horse.currentX;
const size = this.config.horseSize;
const intensity = horse.glowIntensity;
// Outer glow rings
for (let i = 4; i >= 0; i--) {
const ratio = i / 4;
const glowRadius = size * (1.5 + ratio * 2) * intensity;
const alpha = (1 - ratio) * 0.2 * intensity;
this.horseLayer.circle(x, y, glowRadius);
this.horseLayer.fill({ color: COLORS.gold, alpha });
}
// Main body (gold orb)
this.horseLayer.circle(x, y, size);
this.horseLayer.fill({ color: COLORS.gold, alpha: 0.9 });
// Inner bright core
this.horseLayer.circle(x, y, size * 0.6);
this.horseLayer.fill({ color: COLORS.goldLight, alpha: 1 });
// White hot center
this.horseLayer.circle(x, y, size * 0.3);
this.horseLayer.fill({ color: 0xffffff, alpha: 0.8 });
// Draw particles
horse.particles.forEach((p) => {
this.horseLayer.circle(p.x, y + (Math.random() - 0.5) * 10, 3);
this.horseLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
});
}
private addParticleBurst(horse: RaceHorse): void {
const y = this.getTrackY(horse.trackIndex);
for (let i = 0; i < 8; i++) {
horse.particles.push({
x: horse.currentX,
y,
alpha: 1,
speed: 2 + Math.random() * 3,
});
}
}
// ============================================================================
// Animation Loop
// ============================================================================
private update(ticker: Ticker): void {
if (this.isDestroyed) return;
this.time += ticker.deltaMS;
this.horseLayer.clear();
this.horses.forEach((horse) => {
// Smoothly interpolate position
const dx = horse.targetX - horse.currentX;
horse.currentX += dx * 0.08; // Smooth easing
// Smoothly interpolate glow
const dg = horse.targetGlowIntensity - horse.glowIntensity;
horse.glowIntensity += dg * 0.1;
// Update particles (trail effect)
horse.particles = horse.particles.filter((p) => {
p.x -= p.speed;
p.alpha -= 0.02;
return p.alpha > 0;
});
// Add trail particles when moving fast
if (Math.abs(dx) > 5 && Math.random() > 0.7) {
const y = this.getTrackY(horse.trackIndex);
horse.particles.push({
x: horse.currentX - this.config.horseSize,
y,
alpha: 0.6,
speed: 1 + Math.random() * 2,
});
}
this.drawHorse(horse);
});
}
// ============================================================================
// Lifecycle
// ============================================================================
private handleResize(): void {
if (this.isDestroyed) return;
this.app.renderer.resize(window.innerWidth, window.innerHeight);
this.config.maxX = this.app.screen.width - 100;
// Update label positions
this.horses.forEach((horse) => {
const label = this.labelLayer.getChildByName(`label-${horse.id}`) as Text;
if (label) {
label.y = this.getTrackY(horse.trackIndex);
}
horse.targetX = this.calculateHorseX(horse.votes);
});
this.drawTracks();
}
destroy(): void {
this.isDestroyed = true;
window.removeEventListener('resize', this.handleResize.bind(this));
this.horses.clear();
this.app.destroy(true, { children: true });
}
}

View File

@@ -237,6 +237,19 @@ export class LotteryMachine {
* Start the lottery - transition to Galaxy phase
*/
startGalaxy(): void {
// Clear previous round's reveal display
this.winners = [];
this.confettiParticles = [];
this.revealContainer.removeChildren();
this.scrollContainer.removeChildren();
this.confettiContainer.removeChildren();
this.dimOverlay.clear();
// Reset particles
this.nameParticles.forEach((p) => {
p.isWinner = false;
});
this.phase = 'galaxy';
this.phaseTime = 0;
this.onPhaseChange?.('galaxy');
@@ -542,8 +555,18 @@ export class LotteryMachine {
// Spawn confetti when reveal is complete
if (this.revealProgress >= 1 && this.phase === 'reveal') {
this.phase = 'complete';
// Hide all name particles to prevent ghosting
this.nameParticles.forEach((p) => {
if (p.text) {
p.text.alpha = 0;
p.text.visible = false;
}
});
this.spawnConfettiBurst();
this.createWinnerDisplay();
// Don't create Pixi winner display - Vue overlay handles it
// this.createWinnerDisplay();
this.onWinnersRevealed?.(this.winners);
this.onPhaseChange?.('complete');
}

View File

@@ -51,6 +51,12 @@ const router = createRouter({
component: () => import('../views/VoteResultsView.vue'),
meta: { title: '年会大屏 - 投票结果' },
},
{
path: '/screen/horse-race',
name: 'screen-horse-race',
component: () => import('../views/HorseRaceView.vue'),
meta: { title: '年会大屏 - 赛马热度' },
},
// Legacy routes (redirect to new paths)
{ path: '/voting', redirect: '/screen/voting' },

View File

@@ -43,6 +43,24 @@ export const useAdminStore = defineStore('admin', () => {
const stormStartedAt = ref<number | null>(null);
const currentWinners = ref<Array<{ id: string; name: string; department: string }>>([]);
// Timer for countdown updates
const currentTime = ref(Date.now());
let countdownTimer: ReturnType<typeof setInterval> | null = null;
function startCountdownTimer() {
if (countdownTimer) return;
countdownTimer = setInterval(() => {
currentTime.value = Date.now();
}, 100);
}
function stopCountdownTimer() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
// Music State
const musicPlaying = ref(false);
const musicTrack = ref<'bgm' | 'lottery' | 'fanfare' | 'none'>('none');
@@ -67,14 +85,14 @@ export const useAdminStore = defineStore('admin', () => {
const canStopLottery = computed(() => {
if (lotterySubPhase.value !== 'STORM') return false;
if (!stormStartedAt.value) return false;
// 3-second safety delay
return Date.now() - stormStartedAt.value >= 3000;
// 3-second safety delay (use currentTime for reactivity)
return currentTime.value - stormStartedAt.value >= 3000;
});
const stopButtonCountdown = computed(() => {
if (lotterySubPhase.value !== 'STORM') return 0;
if (!stormStartedAt.value) return 3;
const elapsed = Date.now() - stormStartedAt.value;
const elapsed = currentTime.value - stormStartedAt.value;
return Math.max(0, Math.ceil((3000 - elapsed) / 1000));
});
@@ -86,6 +104,7 @@ export const useAdminStore = defineStore('admin', () => {
votingPaused: votingPaused.value,
lotteryRound: lotteryRound.value,
lotterySubPhase: lotterySubPhase.value,
stormStartedAt: stormStartedAt.value,
currentWinners: currentWinners.value,
musicPlaying: musicPlaying.value,
musicTrack: musicTrack.value,
@@ -104,9 +123,15 @@ export const useAdminStore = defineStore('admin', () => {
votingPaused.value = state.votingPaused || false;
lotteryRound.value = state.lotteryRound || 1;
lotterySubPhase.value = state.lotterySubPhase || 'IDLE';
stormStartedAt.value = state.stormStartedAt || null;
currentWinners.value = state.currentWinners || [];
musicPlaying.value = state.musicPlaying || false;
musicTrack.value = state.musicTrack || 'none';
// If in STORM phase but stormStartedAt is missing, set it to allow stopping
if (lotterySubPhase.value === 'STORM' && !stormStartedAt.value) {
stormStartedAt.value = Date.now() - 4000; // Allow immediate stop
}
}
} catch (e) {
console.error('[Admin] Failed to restore state:', e);
@@ -115,11 +140,20 @@ export const useAdminStore = defineStore('admin', () => {
// Watch for state changes and persist
watch(
[systemPhase, votingOpen, votingPaused, lotteryRound, lotterySubPhase, currentWinners, musicPlaying, musicTrack],
[systemPhase, votingOpen, votingPaused, lotteryRound, lotterySubPhase, stormStartedAt, currentWinners, musicPlaying, musicTrack],
() => saveState(),
{ deep: true }
);
// Watch lotterySubPhase to manage countdown timer
watch(lotterySubPhase, (newPhase) => {
if (newPhase === 'STORM') {
startCountdownTimer();
} else {
stopCountdownTimer();
}
}, { immediate: true });
// Connect to server
function connect() {
if (socket.value?.connected || isConnecting.value) return;

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminStore } from '../stores/admin';
import { PRIZE_CONFIG } from '@gala/shared/types';
import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
const router = useRouter();
const admin = useAdminStore();
@@ -12,9 +13,120 @@ const confirmResetCode = ref('');
const showResetModal = ref(false);
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
// Countdown timer for stop button
const countdownDisplay = ref(3);
let countdownInterval: ReturnType<typeof setInterval> | null = null;
// Prize configuration state
const showPrizeConfig = ref(false);
const prizeConfigLoading = ref(false);
const prizeConfigSaving = ref(false);
const editingPrizes = ref<PrizeConfig[]>([]);
// Load prize configuration from server
async function loadPrizeConfig() {
prizeConfigLoading.value = true;
try {
const res = await fetch('/api/admin/prizes');
const data = await res.json();
if (data.success && data.data?.prizes) {
editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p }));
}
} catch (e) {
console.error('Failed to load prize config:', e);
} finally {
prizeConfigLoading.value = false;
}
}
// Save prize configuration to server
async function savePrizeConfig() {
prizeConfigSaving.value = true;
try {
const res = await fetch('/api/admin/prizes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prizes: editingPrizes.value }),
});
const data = await res.json();
if (data.success) {
showPrizeConfig.value = false;
} else {
alert('保存失败: ' + (data.error || '未知错误'));
}
} catch (e) {
alert('保存失败: ' + (e as Error).message);
} finally {
prizeConfigSaving.value = false;
}
}
// Open prize configuration modal
function openPrizeConfig() {
showPrizeConfig.value = true;
loadPrizeConfig();
}
// Participant import state
const importFile = ref<File | null>(null);
const importing = ref(false);
const importResult = ref<{
success: boolean;
totalCount: number;
importedCount: number;
tagDistribution: Record<string, number>;
errors: string[];
} | null>(null);
// Handle file selection
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
importFile.value = input.files[0];
}
}
// Import participants from Excel
async function importParticipants() {
if (!importFile.value) return;
importing.value = true;
importResult.value = null;
try {
const formData = new FormData();
formData.append('file', importFile.value);
const response = await fetch('/api/admin/participants/import', {
method: 'POST',
body: formData,
});
const data = await response.json();
// 确保保留 success 字段,后端返回结构为 { success: true, data: {...} }
importResult.value = {
success: data.success ?? data.data?.success ?? false,
totalCount: data.data?.totalCount ?? data.totalCount ?? 0,
importedCount: data.data?.importedCount ?? data.importedCount ?? 0,
tagDistribution: data.data?.tagDistribution ?? data.tagDistribution ?? {},
errors: data.data?.errors ?? data.errors ?? [],
};
} catch (error) {
importResult.value = {
success: false,
totalCount: 0,
importedCount: 0,
tagDistribution: {},
errors: [(error as Error).message],
};
} finally {
importing.value = false;
}
}
// Tag display names
const tagLabels: Record<string, string> = {
'6070': '60/70后',
'80': '80后',
'90': '90后',
'horse': '属马',
};
// Navigation
function goBack() {
@@ -85,15 +197,6 @@ function startGalaxy() {
function startStorm() {
admin.controlLottery('start_storm');
// Start countdown
countdownDisplay.value = 3;
countdownInterval = setInterval(() => {
countdownDisplay.value = Math.max(0, countdownDisplay.value - 1);
if (countdownDisplay.value === 0 && countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}, 1000);
}
function stopAndReveal() {
@@ -142,6 +245,11 @@ function playFanfare() {
// Computed helpers
const currentPrizeConfig = computed(() => {
// 优先使用从服务器加载的配置
if (editingPrizes.value.length > 0) {
return editingPrizes.value.find(p => p.round === admin.lotteryRound) || editingPrizes.value[0];
}
// 降级到硬编码配置
return PRIZE_CONFIG.find(p => p.round === admin.lotteryRound) || PRIZE_CONFIG[0];
});
@@ -223,12 +331,8 @@ const awardStats = computed(() => {
// Lifecycle
onMounted(() => {
admin.connect();
});
onUnmounted(() => {
if (countdownInterval) {
clearInterval(countdownInterval);
}
// 加载服务器奖项配置
loadPrizeConfig();
});
</script>
@@ -330,6 +434,37 @@ onUnmounted(() => {
<div v-if="currentPrizeConfig.zodiacFilter" class="prize-filter">
限定: 属马
</div>
<button class="prize-config-btn" @click="openPrizeConfig" title="配置奖项">
配置
</button>
</div>
<!-- Prize Configuration Modal -->
<div v-if="showPrizeConfig" class="modal-overlay" @click.self="showPrizeConfig = false">
<div class="modal-content prize-config-modal">
<div class="modal-header">
<h3>奖项配置</h3>
<button class="close-btn" @click="showPrizeConfig = false">×</button>
</div>
<div class="modal-body">
<div v-if="prizeConfigLoading" class="loading">加载中...</div>
<div v-else class="prize-list">
<div v-for="prize in editingPrizes" :key="prize.round" class="prize-edit-item">
<div class="prize-round"> {{ prize.round }} </div>
<input v-model="prize.level" placeholder="等级名称" class="prize-input" />
<input v-model="prize.name" placeholder="奖品名称" class="prize-input prize-input-wide" />
<input v-model.number="prize.winnerCount" type="number" min="1" class="prize-input prize-input-small" />
<span class="prize-unit"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="ctrl-btn outline" @click="showPrizeConfig = false">取消</button>
<button class="ctrl-btn primary" :disabled="prizeConfigSaving" @click="savePrizeConfig">
{{ prizeConfigSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
<!-- Lottery State Machine Controls -->
@@ -362,11 +497,29 @@ onUnmounted(() => {
@click="stopAndReveal"
>
<span v-if="!admin.canStopLottery" class="countdown">
{{ countdownDisplay }}s
{{ admin.stopButtonCountdown }}s
</span>
<span v-else>停止抽奖</span>
</button>
<!-- REVEAL State -->
<div v-if="admin.lotterySubPhase === 'REVEAL'" class="complete-controls">
<button
class="ctrl-btn primary"
:disabled="isLastRound || admin.pendingAction === 'lottery_next_round'"
@click="nextRound"
>
下一轮
</button>
<button
class="ctrl-btn outline"
:disabled="admin.pendingAction === 'lottery_reset'"
@click="resetLottery"
>
重置
</button>
</div>
<!-- COMPLETE State -->
<div v-if="admin.lotterySubPhase === 'COMPLETE'" class="complete-controls">
<button
@@ -414,6 +567,63 @@ onUnmounted(() => {
</div>
</section>
<!-- Section: Participant Import -->
<section class="control-section import-section">
<div class="section-header">
<h2>🎯 抽奖名单导入</h2>
<span v-if="importResult" class="section-status" :class="{ active: importResult.success }">
{{ importResult.success ? `已导入 ${importResult.importedCount} ` : '导入失败' }}
</span>
</div>
<div class="section-body">
<div class="import-controls">
<label class="file-input-wrapper">
<input
type="file"
accept=".xlsx,.xls"
@change="handleFileSelect"
class="file-input"
/>
<span class="file-input-label">
{{ importFile ? importFile.name : '选择 Excel 文件' }}
</span>
</label>
<button
class="ctrl-btn primary"
:disabled="!importFile || importing"
@click="importParticipants"
>
{{ importing ? '导入中...' : '开始导入' }}
</button>
</div>
<p class="import-hint">支持格式岗位 | 姓名 | 年份</p>
<!-- Import Result -->
<div v-if="importResult" class="import-result" :class="{ error: !importResult.success }">
<div v-if="importResult.success" class="result-stats">
<div class="stat-item">
<span class="stat-label">总行数</span>
<span class="stat-value">{{ importResult.totalCount }}</span>
</div>
<div class="stat-item">
<span class="stat-label">成功导入</span>
<span class="stat-value">{{ importResult.importedCount }}</span>
</div>
<div v-for="(count, tag) in importResult.tagDistribution" :key="tag" class="stat-item">
<span class="stat-label">{{ tagLabels[tag] || tag }}</span>
<span class="stat-value">{{ count }}</span>
</div>
</div>
<div v-if="importResult.errors.length > 0" class="result-errors">
<p v-for="(err, i) in importResult.errors.slice(0, 5)" :key="i" class="error-line">{{ err }}</p>
<p v-if="importResult.errors.length > 5" class="error-more">...还有 {{ importResult.errors.length - 5 }} 条错误</p>
</div>
</div>
</div>
</section>
<!-- Section C: Global Controls -->
<section class="control-section global-section">
<div class="section-header">
@@ -1375,4 +1585,169 @@ $admin-danger: #ef4444;
}
}
}
// Import Section Styles
.import-section {
grid-column: span 2;
}
.import-controls {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.file-input-wrapper {
position: relative;
display: inline-block;
.file-input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.file-input-label {
display: inline-block;
padding: 10px 20px;
background: #252525;
border: 1px dashed #444;
border-radius: 8px;
color: #888;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #666;
color: #ccc;
}
}
}
.import-hint {
font-size: 12px;
color: #666;
margin-bottom: 16px;
}
.import-result {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
padding: 16px;
&.error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
}
.result-stats {
display: flex;
flex-wrap: wrap;
gap: 24px;
.stat-item {
.stat-value {
font-size: 20px;
}
}
}
.result-errors {
margin-top: 12px;
.error-line {
font-size: 12px;
color: #ef4444;
margin-bottom: 4px;
}
.error-more {
font-size: 12px;
color: #888;
font-style: italic;
}
}
// Prize Config Button & Modal
.prize-config-btn {
margin-top: 12px;
padding: 6px 12px;
background: #333;
border: 1px solid #555;
border-radius: 6px;
color: #ccc;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #444;
border-color: #666;
}
}
.prize-config-modal {
width: 600px;
max-width: 90vw;
}
.prize-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.prize-edit-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #252525;
border-radius: 8px;
}
.prize-round {
min-width: 60px;
font-weight: bold;
color: #f59e0b;
}
.prize-input {
padding: 8px 12px;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-size: 14px;
&:focus {
outline: none;
border-color: #f59e0b;
}
}
.prize-input-wide {
flex: 1;
}
.prize-input-small {
width: 60px;
text-align: center;
}
.prize-unit {
color: #888;
font-size: 14px;
}
.loading {
text-align: center;
color: #888;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useDisplayStore } from '../stores/display';
import { HorseRaceRenderer } from '../pixi/HorseRaceRenderer';
import type { AdminState } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
const router = useRouter();
const displayStore = useDisplayStore();
// Canvas ref
const canvasRef = ref<HTMLCanvasElement | null>(null);
let renderer: HorseRaceRenderer | null = null;
// State
const votingOpen = ref(false);
const totalVotes = ref(0);
function goBack() {
router.push('/');
}
// Handle state sync from admin
function handleStateSync(state: AdminState) {
votingOpen.value = state.voting.subPhase === 'OPEN';
totalVotes.value = state.voting.totalVotes;
// Update renderer with programs
if (renderer && state.voting.programs) {
renderer.setPrograms(
state.voting.programs.map((p) => ({
id: p.id,
name: p.name,
votes: p.votes,
}))
);
}
}
// Handle real-time vote updates
function handleVoteUpdate(data: { candidateId: string; totalVotes: number }) {
if (renderer) {
renderer.updateVotes(data.candidateId, data.totalVotes);
}
// Update total votes
totalVotes.value = data.totalVotes;
}
onMounted(async () => {
await nextTick();
if (canvasRef.value) {
renderer = new HorseRaceRenderer();
await renderer.init(canvasRef.value);
}
const socket = displayStore.getSocket();
if (socket) {
socket.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, handleStateSync);
socket.on(SOCKET_EVENTS.VOTE_UPDATED as any, handleVoteUpdate);
// Request initial state
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
}
});
onUnmounted(() => {
if (renderer) {
renderer.destroy();
renderer = null;
}
const socket = displayStore.getSocket();
if (socket) {
socket.off(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, handleStateSync);
socket.off(SOCKET_EVENTS.VOTE_UPDATED as any, handleVoteUpdate);
}
});
</script>
<template>
<div class="horse-race-view">
<!-- Canvas Background -->
<canvas ref="canvasRef" class="race-canvas"></canvas>
<!-- Overlay UI -->
<div class="overlay-ui">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title">🏇 节目热度赛马</h1>
<div class="header-right">
<div class="vote-counter">
<span class="counter-label">总票数</span>
<span class="counter-value">{{ totalVotes.toLocaleString() }}</span>
</div>
<div class="status">
<span class="status-badge" :class="{ open: votingOpen }">
{{ votingOpen ? '投票进行中' : '投票未开始' }}
</span>
<span class="online-count">{{ displayStore.onlineUsers }} 人在线</span>
<span class="connection-dot" :class="{ connected: displayStore.isConnected }"></span>
</div>
</div>
</header>
<!-- Legend -->
<div class="legend">
<div class="legend-item">
<span class="legend-icon start"></span>
<span>起点</span>
</div>
<div class="legend-item">
<span class="legend-icon finish"></span>
<span>终点</span>
</div>
<div class="legend-item">
<span class="legend-icon horse"></span>
<span>热度光球</span>
</div>
</div>
<!-- Footer Hint -->
<div class="footer-hint">
<span>💡 每收到一票对应节目的马匹将向前推进</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$color-gold: #f0c239;
$color-red: #c21f30;
$color-text-muted: #888;
.horse-race-view {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: #0a0a0a;
}
.race-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.overlay-ui {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
pointer-events: none;
> * {
pointer-events: auto;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
.back-btn {
background: rgba(0, 0, 0, 0.5);
border: 1px solid $color-gold;
color: $color-gold;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
backdrop-filter: blur(10px);
&:hover {
background: rgba($color-gold, 0.2);
}
}
.title {
font-size: 32px;
font-family: 'SimSun', 'Songti SC', serif;
font-weight: bold;
color: $color-gold;
text-shadow: 0 2px 10px rgba($color-gold, 0.5);
}
.header-right {
display: flex;
align-items: center;
gap: 24px;
}
.vote-counter {
display: flex;
flex-direction: column;
align-items: flex-end;
background: rgba(0, 0, 0, 0.5);
padding: 8px 16px;
border-radius: 8px;
border: 1px solid rgba($color-gold, 0.3);
backdrop-filter: blur(10px);
.counter-label {
font-size: 12px;
color: $color-text-muted;
}
.counter-value {
font-size: 28px;
font-weight: bold;
color: $color-gold;
font-family: 'Courier New', monospace;
}
}
.status {
display: flex;
align-items: center;
gap: 12px;
.status-badge {
padding: 6px 16px;
border-radius: 4px;
font-size: 14px;
background: rgba(255, 255, 255, 0.1);
color: $color-text-muted;
border: 1px solid rgba(255, 255, 255, 0.2);
&.open {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
border-color: rgba(34, 197, 94, 0.4);
}
}
.online-count {
font-size: 14px;
color: $color-text-muted;
}
.connection-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #666;
&.connected {
background: #4ade80;
box-shadow: 0 0 10px rgba(74, 222, 128, 0.5);
}
}
}
}
.legend {
position: absolute;
bottom: 80px;
left: 40px;
display: flex;
gap: 24px;
background: rgba(0, 0, 0, 0.6);
padding: 12px 24px;
border-radius: 8px;
backdrop-filter: blur(10px);
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: $color-text-muted;
}
.legend-icon {
width: 16px;
height: 16px;
border-radius: 50%;
&.start {
background: $color-gold;
opacity: 0.5;
}
&.finish {
background: linear-gradient(135deg, $color-gold 50%, transparent 50%);
border: 2px solid $color-gold;
}
&.horse {
background: radial-gradient(circle, #fff 0%, $color-gold 50%, transparent 100%);
box-shadow: 0 0 10px $color-gold;
}
}
}
.footer-hint {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
color: $color-text-muted;
backdrop-filter: blur(10px);
}
</style>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useSocketClient } from '../composables/useSocketClient';
import { LotteryMachine, type Participant, type LotteryPhase } from '../pixi/LotteryMachine';
import type { AdminState } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
const router = useRouter();
const { isConnected, onlineUsers, lotteryState, onLotteryStateChange } = useSocketClient();
const { isConnected, onlineUsers, adminState, onAdminStateChange } = useSocketClient();
// Pixi canvas ref
const canvasRef = ref<HTMLCanvasElement | null>(null);
@@ -13,41 +15,67 @@ let lotteryMachine: LotteryMachine | null = null;
// Local state
const currentPhase = ref<LotteryPhase>('idle');
const currentPrize = ref({ level: '特等奖', name: 'iPhone 16 Pro Max' });
const currentPrize = ref({ level: '', name: '' });
const winners = ref<Participant[]>([]);
const currentRound = ref(1);
const isLoading = ref(true);
const participantCount = ref(0);
// Prize configuration
const prizes = [
{ level: '特等奖', name: 'iPhone 16 Pro Max', winnerCount: 1 },
{ level: '一等奖', name: 'iPad Pro', winnerCount: 3 },
{ level: '二等奖', name: 'AirPods Pro', winnerCount: 5 },
{ level: '三等奖', name: '京东卡 500元', winnerCount: 10 },
];
// Track previous lottery subPhase to detect changes
let previousSubPhase: string | null = null;
// Mock participants (would come from API)
const mockParticipants: Participant[] = [
{ id: '1', name: '张三', department: '技术部', zodiac: 'horse', age: 28 },
{ id: '2', name: '李四', department: '产品部', zodiac: 'dragon', age: 32 },
{ id: '3', name: '王五', department: '设计部', zodiac: 'horse', age: 26 },
{ id: '4', name: '赵六', department: '市场部', zodiac: 'tiger', age: 35 },
{ id: '5', name: '钱七', department: '运营部', zodiac: 'horse', age: 29 },
{ id: '6', name: '孙八', department: '人事部', zodiac: 'rabbit', age: 31 },
{ id: '7', name: '周九', department: '财务部', zodiac: 'snake', age: 27 },
{ id: '8', name: '吴十', department: '销售部', zodiac: 'horse', age: 33 },
{ id: '9', name: '郑十一', department: '技术部', zodiac: 'monkey', age: 25 },
{ id: '10', name: '王十二', department: '产品部', zodiac: 'horse', age: 30 },
{ id: '11', name: '冯十三', department: '设计部', zodiac: 'rooster', age: 28 },
{ id: '12', name: '陈十四', department: '市场部', zodiac: 'dog', age: 34 },
{ id: '13', name: '褚十五', department: '运营部', zodiac: 'horse', age: 26 },
{ id: '14', name: '卫十六', department: '人事部', zodiac: 'pig', age: 29 },
{ id: '15', name: '蒋十七', department: '财务部', zodiac: 'rat', age: 31 },
{ id: '16', name: '沈十八', department: '销售部', zodiac: 'ox', age: 27 },
{ id: '17', name: '韩十九', department: '技术部', zodiac: 'horse', age: 32 },
{ id: '18', name: '杨二十', department: '产品部', zodiac: 'tiger', age: 28 },
{ id: '19', name: '朱廿一', department: '设计部', zodiac: 'rabbit', age: 25 },
{ id: '20', name: '秦廿二', department: '市场部', zodiac: 'horse', age: 30 },
];
// Prize configuration - 从服务器加载
const prizes = ref<Array<{ level: string; name: string; winnerCount: number; poolTag?: string }>>([]);
// 从 API 获取奖项配置
async function fetchPrizes() {
try {
const response = await fetch('/api/admin/prizes');
const data = await response.json();
if (data.success && data.data?.prizes) {
prizes.value = data.data.prizes;
// 更新当前奖品显示
const currentConfig = data.data.prizes.find((p: any) => p.round === currentRound.value);
if (currentConfig) {
currentPrize.value = { level: currentConfig.level, name: currentConfig.name };
}
console.log('[抽奖] 已加载奖项配置:', prizes.value);
}
} catch (error) {
console.error('[抽奖] 获取奖项配置失败:', error);
}
}
// 从服务器加载的参与者列表
let realParticipants: Participant[] = [];
// 从 API 获取导入的参与者名单
async function fetchParticipants() {
try {
isLoading.value = true;
const response = await fetch('/api/admin/participants');
const data = await response.json();
if (data.success && data.data?.participants) {
// 将后端格式转换为前端 Participant 格式
realParticipants = data.data.participants.map((p: any) => ({
id: p.id,
name: p.name,
department: p.position, // 岗位作为部门显示
zodiac: p.tags?.includes('horse') ? 'horse' : 'other',
age: new Date().getFullYear() - p.birthYear,
}));
participantCount.value = realParticipants.length;
console.log(`[抽奖] 已加载 ${realParticipants.length} 名参与者`);
} else {
console.warn('[抽奖] 未找到参与者数据,请先导入名单');
}
} catch (error) {
console.error('[抽奖] 获取参与者失败:', error);
} finally {
isLoading.value = false;
}
}
// Unsubscribe function
let unsubscribeLottery: (() => void) | null = null;
@@ -64,12 +92,68 @@ function handleWinnersRevealed(revealedWinners: Participant[]) {
winners.value = revealedWinners;
}
// Handle admin state changes from control panel
function handleAdminStateChange(state: AdminState) {
const lottery = state.lottery;
const newSubPhase = lottery.subPhase;
// Update local state
currentRound.value = lottery.round;
currentPrize.value = {
level: lottery.prizeLevel,
name: lottery.prizeName,
};
// Detect phase transitions and trigger actions
if (newSubPhase !== previousSubPhase) {
console.log(`[BigScreen] Lottery phase: ${previousSubPhase} -> ${newSubPhase}`);
if (newSubPhase === 'GALAXY') {
// Clear winners when starting new round
winners.value = [];
startGalaxy();
} else if (newSubPhase === 'STORM') {
startRolling();
} else if (newSubPhase === 'REVEAL') {
// Use winners from server instead of local random
if (lottery.currentWinners && lottery.currentWinners.length > 0) {
const serverWinners = lottery.currentWinners.map(w => ({
id: w.id,
name: w.name,
department: w.department,
zodiac: 'unknown' as const,
age: 0,
}));
winners.value = serverWinners;
stopAndRevealWithWinners(serverWinners);
} else {
stopAndReveal();
}
} else if (newSubPhase === 'IDLE' && previousSubPhase === 'COMPLETE') {
resetLottery();
}
previousSubPhase = newSubPhase;
}
}
// Control functions
function startGalaxy() {
if (!lotteryMachine) return;
// Clear previous winners
winners.value = [];
// 使用真实参与者数据(如果有的话)
const participants = realParticipants.length > 0 ? realParticipants : [];
if (participants.length === 0) {
console.warn('[抽奖] 无参与者数据,请先导入名单');
return;
}
// Set participants and start galaxy view
lotteryMachine.setParticipants(mockParticipants);
lotteryMachine.setParticipants(participants);
// Apply filter for Round 4 (Horse zodiac)
if (currentRound.value === 4) {
@@ -87,9 +171,10 @@ function startRolling() {
function stopAndReveal() {
if (!lotteryMachine) return;
// Pick random winners
const prize = prizes[currentRound.value - 1];
const eligibleParticipants = mockParticipants.filter(p => {
// 使用真实参与者数据进行本地随机抽取(服务器未返回中奖者的降级方案)
const participants = realParticipants.length > 0 ? realParticipants : [];
const prize = prizes.value.find(p => p.round === currentRound.value) || prizes.value[0];
const eligibleParticipants = participants.filter(p => {
if (currentRound.value === 4) {
return p.zodiac === 'horse';
}
@@ -97,7 +182,24 @@ function stopAndReveal() {
});
const shuffled = [...eligibleParticipants].sort(() => Math.random() - 0.5);
const winnerIds = shuffled.slice(0, prize.winnerCount).map(p => p.id);
const winnerIds = shuffled.slice(0, prize?.winnerCount || 1).map(p => p.id);
lotteryMachine.revealWinners(winnerIds);
}
// Reveal winners using server-provided data
function stopAndRevealWithWinners(serverWinners: Participant[]) {
if (!lotteryMachine) return;
// Map server winners to participant IDs for the lottery machine
const winnerIds = serverWinners.map(w => w.id);
// Add server winners to participants if not already present
serverWinners.forEach(sw => {
if (!realParticipants.find(p => p.id === sw.id)) {
realParticipants.push(sw);
}
});
lotteryMachine.revealWinners(winnerIds);
}
@@ -106,10 +208,10 @@ function nextRound() {
if (!lotteryMachine) return;
currentRound.value = Math.min(4, currentRound.value + 1);
currentPrize.value = {
level: prizes[currentRound.value - 1].level,
name: prizes[currentRound.value - 1].name,
};
const nextPrize = prizes.value.find(p => p.round === currentRound.value);
if (nextPrize) {
currentPrize.value = { level: nextPrize.level, name: nextPrize.name };
}
winners.value = [];
lotteryMachine.reset();
@@ -119,7 +221,10 @@ function resetLottery() {
if (!lotteryMachine) return;
currentRound.value = 1;
currentPrize.value = { level: prizes[0].level, name: prizes[0].name };
const firstPrize = prizes.value.find(p => p.round === 1) || prizes.value[0];
if (firstPrize) {
currentPrize.value = { level: firstPrize.level, name: firstPrize.name };
}
winners.value = [];
lotteryMachine.reset();
@@ -128,6 +233,9 @@ function resetLottery() {
onMounted(async () => {
await nextTick();
// 首先从服务器加载配置和参与者数据
await Promise.all([fetchPrizes(), fetchParticipants()]);
// Initialize lottery machine
if (canvasRef.value) {
lotteryMachine = new LotteryMachine();
@@ -138,12 +246,14 @@ onMounted(async () => {
lotteryMachine.onWinners(handleWinnersRevealed);
}
// Subscribe to lottery state changes from server
unsubscribeLottery = onLotteryStateChange((state) => {
if (state.phase === 'rolling' && currentPhase.value !== 'storm') {
startRolling();
}
});
// Subscribe to admin state changes from control panel
unsubscribeLottery = onAdminStateChange(handleAdminStateChange);
// Request initial admin state
const socket = useSocketClient().getSocket();
if (socket) {
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
}
});
onUnmounted(() => {
@@ -208,48 +318,16 @@ onUnmounted(() => {
</div>
</div>
<!-- Control Panel -->
<div class="control-panel">
<button
v-if="currentPhase === 'idle'"
class="control-btn primary"
@click="startGalaxy"
>
开始展示
</button>
<button
v-if="currentPhase === 'galaxy'"
class="control-btn primary"
@click="startRolling"
>
开始抽奖
</button>
<button
v-if="currentPhase === 'storm'"
class="control-btn danger"
@click="stopAndReveal"
>
停止抽奖
</button>
<button
v-if="currentPhase === 'complete'"
class="control-btn secondary"
@click="nextRound"
:disabled="currentRound >= 4"
>
下一轮
</button>
<button
v-if="currentPhase === 'complete'"
class="control-btn outline"
@click="resetLottery"
>
重置
</button>
<!-- Status Panel (controlled by admin) -->
<div class="status-panel">
<span class="status-text">
{{ currentPhase === 'idle' ? '等待管理员开始...' :
currentPhase === 'galaxy' ? '参与者展示中' :
currentPhase === 'storm' ? '抽奖进行中...' :
currentPhase === 'reveal' ? '揭晓中...' :
'本轮抽奖完成' }}
</span>
<span class="admin-hint">由管理控制台控制</span>
</div>
</div>
</div>
@@ -413,10 +491,14 @@ onUnmounted(() => {
text-align: center;
pointer-events: none;
z-index: 100;
// Constrain to scroll area (600x400 with padding)
max-width: 560px;
max-height: 360px;
overflow-y: auto;
.winners-title {
font-size: 36px;
margin-bottom: 30px;
font-size: 28px;
margin-bottom: 20px;
text-shadow: $glow-gold;
}
@@ -424,88 +506,60 @@ onUnmounted(() => {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
max-width: 800px;
gap: 12px;
padding: 0 10px;
}
.winner-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 30px;
background: rgba(0, 0, 0, 0.7);
border: 2px solid $color-gold;
border-radius: 12px;
backdrop-filter: blur(10px);
padding: 12px 16px;
min-width: 80px;
background: transparent;
border: none;
border-radius: 8px;
.winner-name {
font-size: 28px;
font-size: 22px;
color: $color-gold;
font-weight: bold;
margin-bottom: 8px;
margin-bottom: 4px;
text-shadow: $glow-gold;
}
.winner-dept {
font-size: 16px;
color: $color-text-muted;
font-size: 12px;
color: $color-gold;
opacity: 0.8;
}
}
}
.control-panel {
.status-panel {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 40px;
background: rgba(0, 0, 0, 0.5);
border-radius: 12px;
backdrop-filter: blur(10px);
.control-btn {
padding: 16px 40px;
font-size: 20px;
.status-text {
font-size: 18px;
color: $color-gold;
font-weight: bold;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: scale(1.05);
}
&.primary {
background: linear-gradient(135deg, $color-gold-dark, $color-gold);
color: #000;
box-shadow: $glow-gold;
}
&.danger {
background: linear-gradient(135deg, $color-primary-dark, $color-primary);
color: $color-text-light;
box-shadow: $glow-red;
}
&.secondary {
background: rgba($color-gold, 0.2);
color: $color-gold;
border: 2px solid $color-gold;
}
&.outline {
background: transparent;
color: $color-text-muted;
border: 1px solid $color-text-muted;
&:hover:not(:disabled) {
border-color: $color-text-light;
color: $color-text-light;
}
}
.admin-hint {
font-size: 12px;
color: $color-text-muted;
opacity: 0.7;
}
}
</style>

View File

@@ -0,0 +1,42 @@
{
"prizes": [
{
"round": 1,
"level": "第一轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 7,
"poolTag": "6070",
"description": "60/70年代"
},
{
"round": 2,
"level": "第二轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 3,
"poolTag": "80",
"description": "80年代"
},
{
"round": 3,
"level": "第三轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 4,
"poolTag": "90",
"description": "90年代"
},
{
"round": 4,
"level": "第四轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 1,
"poolTag": "horse",
"zodiacFilter": "horse",
"description": "属马特供"
}
],
"settings": {
"minStormDuration": 3000,
"revealAnimationDuration": 2000,
"allowRepeatWinner": false
}
}

View File

@@ -17,27 +17,32 @@
},
"dependencies": {
"@gala/shared": "workspace:*",
"express": "^4.21.2",
"socket.io": "^4.8.1",
"@socket.io/redis-adapter": "^8.3.0",
"ioredis": "^5.4.2",
"@prisma/client": "^6.2.1",
"zod": "^3.24.1",
"cors": "^2.8.5",
"helmet": "^8.0.0",
"@socket.io/redis-adapter": "^8.3.0",
"compression": "^1.7.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"ioredis": "^5.4.2",
"multer": "^2.0.2",
"nanoid": "^5.0.9",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"nanoid": "^5.0.9"
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^2.0.0",
"@types/uuid": "^11.0.0",
"prisma": "^6.2.1",
"tsx": "^4.19.2",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
}

View File

@@ -5,6 +5,8 @@ import { connectRedis } from './config/redis';
import { initializeSocket } from './socket';
import { loadLuaScripts } from './services/vote.service';
import { loadVotingScripts } from './services/voting.engine';
import { prizeConfigService } from './services/prize-config.service';
import { participantService } from './services/participant.service';
async function main(): Promise<void> {
try {
@@ -17,6 +19,14 @@ async function main(): Promise<void> {
await loadLuaScripts();
await loadVotingScripts();
// Load prize configuration
logger.info('Loading prize configuration...');
await prizeConfigService.load();
// Restore participants from Redis
logger.info('Restoring participants from Redis...');
await participantService.restoreFromRedis();
// Create HTTP server
const httpServer = createServer(app);
@@ -55,3 +65,4 @@ async function main(): Promise<void> {
}
main();

View File

@@ -1,6 +1,10 @@
import { Router, IRouter } from 'express';
import multer from 'multer';
import { participantService } from '../services/participant.service';
import { prizeConfigService } from '../services/prize-config.service';
const router: IRouter = Router();
const upload = multer({ storage: multer.memoryStorage() });
/**
* GET /api/admin/stats
@@ -12,7 +16,7 @@ router.get('/stats', async (_req, res, next) => {
return res.json({
success: true,
data: {
totalUsers: 0,
totalUsers: participantService.getCount(),
totalVotes: 0,
activeConnections: 0,
},
@@ -22,6 +26,53 @@ router.get('/stats', async (_req, res, next) => {
}
});
/**
* GET /api/admin/prizes
* Get prize configuration
*/
router.get('/prizes', async (_req, res, next) => {
try {
const config = prizeConfigService.getFullConfig();
return res.json({
success: true,
data: config,
});
} catch (error) {
next(error);
}
});
/**
* PUT /api/admin/prizes
* Update prize configuration
*/
router.put('/prizes', async (req, res, next) => {
try {
const { prizes, settings } = req.body;
if (prizes) {
const result = await prizeConfigService.updatePrizes(prizes);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
}
if (settings) {
const result = await prizeConfigService.updateSettings(settings);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
}
return res.json({
success: true,
data: prizeConfigService.getFullConfig(),
});
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/draw/start
* Start a lucky draw
@@ -54,4 +105,54 @@ router.post('/draw/stop', async (_req, res, next) => {
}
});
/**
* POST /api/admin/participants/import
* Import participants from Excel file
* Expected columns: 岗位, 姓名, 年份
*/
router.post('/participants/import', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: '请上传 Excel 文件',
});
}
const result = await participantService.importFromExcel(req.file.buffer);
return res.json({
success: result.success,
data: {
totalCount: result.totalCount,
importedCount: result.importedCount,
tagDistribution: result.tagDistribution,
errors: result.errors,
},
});
} catch (error) {
next(error);
}
});
/**
* GET /api/admin/participants
* Get all participants
*/
router.get('/participants', async (_req, res, next) => {
try {
const participants = participantService.getAll();
return res.json({
success: true,
data: {
count: participants.length,
participants,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -3,6 +3,8 @@
*/
import { redis } from '../config/redis';
import { logger } from '../utils/logger';
import { prizeConfigService } from './prize-config.service';
import { participantService } from './participant.service';
import type {
AdminState,
SystemPhase,
@@ -60,11 +62,37 @@ class AdminService {
await this.saveState();
logger.info('Admin state initialized with defaults');
}
// 从配置文件刷新当前轮次的奖项信息
await this.refreshPrizeFromConfig();
} catch (error) {
logger.error({ error }, 'Failed to initialize admin state');
}
}
/**
* Refresh lottery prize info from config file
*/
async refreshPrizeFromConfig(): Promise<void> {
try {
const prizes = prizeConfigService.getPrizes();
const currentPrize = prizes.find(p => p.round === this.state.lottery.round);
if (currentPrize) {
this.state.lottery.prizeLevel = currentPrize.level;
this.state.lottery.prizeName = currentPrize.name;
this.state.lottery.winnerCount = currentPrize.winnerCount;
await this.saveState();
logger.info({
round: this.state.lottery.round,
level: currentPrize.level,
name: currentPrize.name
}, 'Prize config refreshed from config file');
}
} catch (error) {
logger.error({ error }, 'Failed to refresh prize config');
}
}
/**
* Save current state to Redis
*/
@@ -370,7 +398,9 @@ class AdminService {
return { success: false, message: 'Already at final round' };
}
const nextRound = (this.state.lottery.round + 1) as LotteryRound;
const prizeConfig = PRIZE_CONFIG.find(p => p.round === nextRound);
// 使用服务器配置
const serverPrizes = prizeConfigService.getPrizes();
const prizeConfig = serverPrizes.find(p => p.round === nextRound) || PRIZE_CONFIG.find(p => p.round === nextRound);
this.state.lottery.round = nextRound;
this.state.lottery.subPhase = 'IDLE';
this.state.lottery.currentWinners = [];
@@ -383,12 +413,15 @@ class AdminService {
break;
case 'reset':
// 使用服务器配置获取第一轮奖项
const resetPrizes = prizeConfigService.getPrizes();
const firstPrize = resetPrizes.find(p => p.round === 1) || resetPrizes[0] || PRIZE_CONFIG[0];
this.state.lottery = {
round: 1,
subPhase: 'IDLE',
prizeLevel: '特等奖',
prizeName: 'iPhone 16 Pro Max',
winnerCount: 1,
prizeLevel: firstPrize?.level || '特等奖',
prizeName: firstPrize?.name || '待配置',
winnerCount: firstPrize?.winnerCount || 1,
currentWinners: [],
};
break;
@@ -403,29 +436,43 @@ class AdminService {
}
/**
* Pick random winners (simplified implementation)
* Pick random winners from real participants
*/
private pickRandomWinners(): LotteryWinner[] {
const prizeConfig = PRIZE_CONFIG.find(p => p.round === this.state.lottery.round);
const prizes = prizeConfigService.getPrizes();
const prizeConfig = prizes.find(p => p.round === this.state.lottery.round) || PRIZE_CONFIG.find(p => p.round === this.state.lottery.round);
const count = prizeConfig?.winnerCount || 1;
// Demo winners for testing
const demoNames = [
{ id: 'w1', name: '张明', department: '技术部' },
{ id: 'w2', name: '李华', department: '市场部' },
{ id: 'w3', name: '王强', department: '产品部' },
{ id: 'w4', name: '赵丽', department: '设计部' },
{ id: 'w5', name: '刘伟', department: '运营部' },
{ id: 'w6', name: '陈晨', department: '财务部' },
{ id: 'w7', name: '周杰', department: '人事部' },
{ id: 'w8', name: '吴欣', department: '销售部' },
{ id: 'w9', name: '郑雪', department: '技术部' },
{ id: 'w10', name: '孙浩', department: '市场部' },
];
// 从导入的参与者中抽取
const allParticipants = participantService.getAll();
// Shuffle and pick
const shuffled = [...demoNames].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
if (allParticipants.length === 0) {
logger.warn('No participants available, using demo data');
// 降级到 demo 数据
const demoNames = [
{ id: 'demo1', name: '张三', department: '待导入' },
{ id: 'demo2', name: '李四', department: '待导入' },
{ id: 'demo3', name: '王五', department: '待导入' },
];
return demoNames.slice(0, count);
}
// 根据当前轮次的 poolTag 过滤参与者
let eligibleParticipants = allParticipants;
const poolTag = (prizeConfig as any)?.poolTag;
if (poolTag && poolTag !== 'all') {
eligibleParticipants = allParticipants.filter((p: any) => p.tags?.includes(poolTag));
}
// 随机抽取
const shuffled = [...eligibleParticipants].sort(() => Math.random() - 0.5);
const winners = shuffled.slice(0, count).map((p: any) => ({
id: p.id,
name: p.name,
department: p.position, // 岗位作为部门显示
}));
return winners;
}
/**

View File

@@ -0,0 +1,295 @@
import { z } from 'zod';
import * as xlsx from 'xlsx';
import { v4 as uuidv4 } from 'uuid';
import { drawEngine, calculateUserTags } from './draw.engine';
import { logger } from '../utils/logger';
import { redis } from '../config/redis';
import type { UserTag } from '@gala/shared/constants';
// Redis key for storing participants
const PARTICIPANTS_KEY = 'gala:participants:list';
// Types
// ============================================================================
export interface Participant {
id: string;
name: string;
position: string; // 岗位
birthYear: number;
tags: UserTag[];
}
export interface ImportResult {
success: boolean;
totalCount: number;
importedCount: number;
tagDistribution: Record<string, number>;
errors: string[];
participants: Participant[];
}
// ============================================================================
// Excel Column Mapping
// ============================================================================
// Expected columns: 岗位, 姓名, 年份
const COLUMN_MAPPING = {
position: ['岗位', '职位', 'Position', 'position'],
name: ['姓名', '名字', 'Name', 'name'],
birthYear: ['年份', '出生年份', 'Year', 'year', '生日'],
};
function findColumnIndex(headers: string[], possibleNames: string[]): number {
for (const name of possibleNames) {
const index = headers.findIndex(
(h) => h && h.toString().trim().toLowerCase() === name.toLowerCase()
);
if (index !== -1) return index;
}
return -1;
}
// ============================================================================
// Participant Service
// ============================================================================
class ParticipantService {
private participants: Map<string, Participant> = new Map();
/**
* Parse Excel buffer and import participants
*/
async importFromExcel(buffer: Buffer): Promise<ImportResult> {
const errors: string[] = [];
const participants: Participant[] = [];
const tagDistribution: Record<string, number> = {};
try {
// Parse Excel
const workbook = xlsx.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
// Convert to JSON with header row
const data = xlsx.utils.sheet_to_json<string[]>(sheet, { header: 1 });
if (data.length < 2) {
return {
success: false,
totalCount: 0,
importedCount: 0,
tagDistribution: {},
errors: ['Excel 文件为空或只有表头'],
participants: [],
};
}
// Find column indices
const headers = data[0] as string[];
const positionIndex = findColumnIndex(headers, COLUMN_MAPPING.position);
const nameIndex = findColumnIndex(headers, COLUMN_MAPPING.name);
const yearIndex = findColumnIndex(headers, COLUMN_MAPPING.birthYear);
if (positionIndex === -1) {
errors.push('未找到"岗位"列');
}
if (nameIndex === -1) {
errors.push('未找到"姓名"列');
}
if (yearIndex === -1) {
errors.push('未找到"年份"列');
}
if (errors.length > 0) {
return {
success: false,
totalCount: data.length - 1,
importedCount: 0,
tagDistribution: {},
errors,
participants: [],
};
}
// Process rows
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row || row.length === 0) continue;
const position = row[positionIndex]?.toString().trim() || '';
const name = row[nameIndex]?.toString().trim() || '';
const yearValue = row[yearIndex];
if (!name) {
errors.push(`${i + 1} 行: 姓名为空`);
continue;
}
let birthYear: number;
if (typeof yearValue === 'number') {
birthYear = yearValue;
} else {
birthYear = parseInt(yearValue?.toString().trim() || '', 10);
}
if (isNaN(birthYear) || birthYear < 1940 || birthYear > 2010) {
errors.push(`${i + 1} 行 (${name}): 年份无效 (${yearValue})`);
continue;
}
// Calculate tags using existing draw engine logic
// Convert year to birth date string format (YYYY-01-01)
const tags = calculateUserTags(`${birthYear}-01-01`);
const participant: Participant = {
id: uuidv4(),
name,
position,
birthYear,
tags,
};
participants.push(participant);
// Update tag distribution
for (const tag of tags) {
tagDistribution[tag] = (tagDistribution[tag] || 0) + 1;
}
}
// Store in memory
this.participants.clear();
for (const p of participants) {
this.participants.set(p.id, p);
}
// Persist to Redis
await this.saveToRedis();
// Initialize draw pools in Redis
await drawEngine.initializePools(
participants.map((p) => ({
userId: p.id,
birthDate: `${p.birthYear}-01-01`,
}))
);
logger.info(
{
totalCount: data.length - 1,
importedCount: participants.length,
tagDistribution,
},
'Participants imported successfully'
);
return {
success: true,
totalCount: data.length - 1,
importedCount: participants.length,
tagDistribution,
errors,
participants,
};
} catch (error) {
logger.error({ error }, 'Failed to import participants from Excel');
return {
success: false,
totalCount: 0,
importedCount: 0,
tagDistribution: {},
errors: [`解析错误: ${(error as Error).message}`],
participants: [],
};
}
}
/**
* Get all participants
*/
getAll(): Participant[] {
return Array.from(this.participants.values());
}
/**
* Get participant by ID
*/
getById(id: string): Participant | undefined {
return this.participants.get(id);
}
/**
* Get participants by tag
*/
getByTag(tag: UserTag): Participant[] {
return this.getAll().filter((p) => p.tags.includes(tag));
}
/**
* Get participant count
*/
getCount(): number {
return this.participants.size;
}
/**
* Save participants to Redis for persistence
*/
private async saveToRedis(): Promise<void> {
try {
const participants = this.getAll();
if (participants.length === 0) {
await redis.del(PARTICIPANTS_KEY);
return;
}
await redis.set(PARTICIPANTS_KEY, JSON.stringify(participants));
logger.info({ count: participants.length }, 'Participants saved to Redis');
} catch (error) {
logger.error({ error }, 'Failed to save participants to Redis');
}
}
/**
* Restore participants from Redis on server start
*/
async restoreFromRedis(): Promise<void> {
try {
const data = await redis.get(PARTICIPANTS_KEY);
if (!data) {
logger.info('No participants found in Redis');
return;
}
const participants: Participant[] = JSON.parse(data);
this.participants.clear();
for (const p of participants) {
this.participants.set(p.id, p);
}
// Re-initialize draw pools
if (participants.length > 0) {
await drawEngine.initializePools(
participants.map((p) => ({
userId: p.id,
birthDate: `${p.birthYear}-01-01`,
}))
);
}
logger.info({ count: participants.length }, 'Participants restored from Redis');
} catch (error) {
logger.error({ error }, 'Failed to restore participants from Redis');
}
}
/**
* Clear all participants (for admin reset)
*/
async clear(): Promise<void> {
this.participants.clear();
await redis.del(PARTICIPANTS_KEY);
logger.info('Participants cleared');
}
}
export const participantService = new ParticipantService();

View File

@@ -0,0 +1,150 @@
/**
* Prize Configuration Service
* Loads prize config from JSON file and provides API for management
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../utils/logger';
import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Default config path
const CONFIG_PATH = path.join(__dirname, '../../config/prizes.json');
export interface PrizeSettings {
minStormDuration: number;
revealAnimationDuration: number;
allowRepeatWinner: boolean;
}
export interface PrizeConfigFile {
prizes: PrizeConfig[];
settings: PrizeSettings;
}
class PrizeConfigService {
private config: PrizeConfigFile | null = null;
private configPath: string;
constructor() {
this.configPath = CONFIG_PATH;
}
/**
* Load config from file
*/
async load(): Promise<void> {
try {
if (fs.existsSync(this.configPath)) {
const content = fs.readFileSync(this.configPath, 'utf-8');
this.config = JSON.parse(content);
logger.info({
prizeCount: this.config?.prizes.length,
configPath: this.configPath
}, 'Prize config loaded');
} else {
logger.warn({ configPath: this.configPath }, 'Prize config file not found, using defaults');
this.config = this.getDefaults();
}
} catch (error) {
logger.error({ error, configPath: this.configPath }, 'Failed to load prize config');
this.config = this.getDefaults();
}
}
/**
* Get default configuration
*/
private getDefaults(): PrizeConfigFile {
return {
prizes: [
{ round: 1 as LotteryRound, level: '特等奖', name: 'iPhone 16 Pro Max', winnerCount: 1 },
{ round: 2 as LotteryRound, level: '一等奖', name: 'iPad Pro', winnerCount: 3 },
{ round: 3 as LotteryRound, level: '二等奖', name: 'AirPods Pro', winnerCount: 5 },
{ round: 4 as LotteryRound, level: '三等奖', name: '京东卡 500元', winnerCount: 10, zodiacFilter: 'horse' },
],
settings: {
minStormDuration: 3000,
revealAnimationDuration: 2000,
allowRepeatWinner: false,
},
};
}
/**
* Get all prizes
*/
getPrizes(): PrizeConfig[] {
return this.config?.prizes || this.getDefaults().prizes;
}
/**
* Get prize by round
*/
getPrizeByRound(round: LotteryRound): PrizeConfig | undefined {
return this.getPrizes().find(p => p.round === round);
}
/**
* Get settings
*/
getSettings(): PrizeSettings {
return this.config?.settings || this.getDefaults().settings;
}
/**
* Update prizes and save to file
*/
async updatePrizes(prizes: PrizeConfig[]): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
this.config = this.getDefaults();
}
this.config.prizes = prizes;
await this.saveToFile();
logger.info({ prizeCount: prizes.length }, 'Prizes updated');
return { success: true };
} catch (error) {
logger.error({ error }, 'Failed to update prizes');
return { success: false, error: (error as Error).message };
}
}
/**
* Update settings and save to file
*/
async updateSettings(settings: Partial<PrizeSettings>): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
this.config = this.getDefaults();
}
this.config.settings = { ...this.config.settings, ...settings };
await this.saveToFile();
logger.info({ settings: this.config.settings }, 'Prize settings updated');
return { success: true };
} catch (error) {
logger.error({ error }, 'Failed to update settings');
return { success: false, error: (error as Error).message };
}
}
/**
* Save config to file
*/
private async saveToFile(): Promise<void> {
const content = JSON.stringify(this.config, null, 2);
fs.writeFileSync(this.configPath, content, 'utf-8');
}
/**
* Get full config for API response
*/
getFullConfig(): PrizeConfigFile {
return this.config || this.getDefaults();
}
}
export const prizeConfigService = new PrizeConfigService();

185
pnpm-lock.yaml generated
View File

@@ -166,6 +166,9 @@ importers:
ioredis:
specifier: ^5.4.2
version: 5.9.1
multer:
specifier: ^2.0.2
version: 2.0.2
nanoid:
specifier: ^5.0.9
version: 5.1.6
@@ -178,6 +181,12 @@ importers:
socket.io:
specifier: ^4.8.1
version: 4.8.3
uuid:
specifier: ^13.0.0
version: 13.0.0
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod:
specifier: ^3.24.1
version: 3.25.76
@@ -191,6 +200,12 @@ importers:
'@types/express':
specifier: ^5.0.0
version: 5.0.6
'@types/multer':
specifier: ^2.0.0
version: 2.0.0
'@types/uuid':
specifier: ^11.0.0
version: 11.0.0
prisma:
specifier: ^6.2.1
version: 6.19.2(typescript@5.9.3)
@@ -1673,6 +1688,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/multer@2.0.0':
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
'@types/node@25.0.8':
resolution: {integrity: sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==}
@@ -1694,6 +1712,10 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/uuid@11.0.0':
resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==}
deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.
'@typescript-eslint/eslint-plugin@8.53.0':
resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1873,6 +1895,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -1905,6 +1931,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2009,6 +2038,10 @@ packages:
peerDependencies:
esbuild: '>=0.18'
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -2044,6 +2077,10 @@ packages:
caniuse-lite@1.0.30001764:
resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -2071,6 +2108,10 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2107,6 +2148,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -2142,6 +2187,11 @@ packages:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2555,6 +2605,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@@ -3059,6 +3113,10 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@@ -3071,6 +3129,10 @@ packages:
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
multer@2.0.2:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
engines: {node: '>= 10.16.0'}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -3388,6 +3450,10 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@@ -3619,6 +3685,10 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -3636,6 +3706,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -3660,6 +3734,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-object@3.3.0:
resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==}
engines: {node: '>=4'}
@@ -3833,6 +3910,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -3936,6 +4016,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
vant@4.9.22:
resolution: {integrity: sha512-P2PDSj3oB6l3W1OpVlQpapeLmI6bXMSvPqPdrw5rutslC0Y6tSkrVB/iSD57weD7K92GsjGkvgDK0eZlOsXGqw==}
peerDependencies:
@@ -4132,10 +4216,18 @@ packages:
engines: {node: '>=8'}
hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
workbox-background-sync@7.4.0:
resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==}
@@ -4208,6 +4300,11 @@ packages:
utf-8-validate:
optional: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
@@ -4216,6 +4313,10 @@ packages:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -5579,6 +5680,10 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/multer@2.0.0':
dependencies:
'@types/express': 5.0.6
'@types/node@25.0.8':
dependencies:
undici-types: 7.16.0
@@ -5600,6 +5705,10 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/uuid@11.0.0':
dependencies:
uuid: 13.0.0
'@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -5845,6 +5954,8 @@ snapshots:
acorn@8.15.0: {}
adler-32@1.3.1: {}
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -5878,6 +5989,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
append-field@1.0.0: {}
argparse@2.0.1: {}
array-buffer-byte-length@1.0.2:
@@ -6000,6 +6113,10 @@ snapshots:
esbuild: 0.27.2
load-tsconfig: 0.2.5
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
bytes@3.1.2: {}
c12@3.1.0:
@@ -6040,6 +6157,11 @@ snapshots:
caniuse-lite@1.0.30001764: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -6077,6 +6199,8 @@ snapshots:
cluster-key-slot@1.1.2: {}
codepage@1.15.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -6113,6 +6237,13 @@ snapshots:
concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
confbox@0.1.8: {}
confbox@0.2.2: {}
@@ -6140,6 +6271,8 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
crc-32@1.2.2: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -6710,6 +6843,8 @@ snapshots:
forwarded@0.2.0: {}
frac@1.1.2: {}
fresh@0.5.2: {}
fs-extra@9.1.0:
@@ -7178,6 +7313,10 @@ snapshots:
minipass@7.1.2: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
mlly@1.8.0:
dependencies:
acorn: 8.15.0
@@ -7191,6 +7330,16 @@ snapshots:
muggle-string@0.4.1: {}
multer@2.0.2:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
mkdirp: 0.5.6
object-assign: 4.1.1
type-is: 1.6.18
xtend: 4.0.2
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -7491,6 +7640,12 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
@@ -7801,6 +7956,10 @@ snapshots:
split2@4.2.0: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stackback@0.0.2: {}
standard-as-callback@2.1.0: {}
@@ -7814,6 +7973,8 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
streamsearch@1.1.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -7865,6 +8026,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
stringify-object@3.3.0:
dependencies:
get-own-enumerable-property-symbols: 3.0.2
@@ -8051,6 +8216,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typedarray@0.0.6: {}
typescript@5.9.3: {}
ufo@1.6.2: {}
@@ -8162,6 +8329,8 @@ snapshots:
utils-merge@1.0.1: {}
uuid@13.0.0: {}
vant@4.9.22(vue@3.5.26(typescript@5.9.3)):
dependencies:
'@vant/popperjs': 1.3.0
@@ -8362,8 +8531,12 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wmf@1.0.2: {}
word-wrap@1.2.5: {}
word@0.3.0: {}
workbox-background-sync@7.4.0:
dependencies:
idb: 7.1.1
@@ -8493,10 +8666,22 @@ snapshots:
ws@8.18.3: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@4.0.0: {}
xmlhttprequest-ssl@2.1.2: {}
xtend@4.0.2: {}
yallist@3.1.1: {}
yocto-queue@0.1.0: {}