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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
64
packages/client-mobile/src/utils/svgIcons.ts
Normal file
64
packages/client-mobile/src/utils/svgIcons.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@ const TICKET_TYPE_NAMES: Record<string, string> = {
|
||||
potential: '最具潜力',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
teamName: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
382
packages/client-screen/src/pixi/HorseRaceRenderer.ts
Normal file
382
packages/client-screen/src/pixi/HorseRaceRenderer.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
315
packages/client-screen/src/views/HorseRaceView.vue
Normal file
315
packages/client-screen/src/views/HorseRaceView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
42
packages/server/config/prizes.json
Normal file
42
packages/server/config/prizes.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
295
packages/server/src/services/participant.service.ts
Normal file
295
packages/server/src/services/participant.service.ts
Normal 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();
|
||||
150
packages/server/src/services/prize-config.service.ts
Normal file
150
packages/server/src/services/prize-config.service.ts
Normal 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
185
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user