feat: enhance lottery system with participant import and prize config

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

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

View File

@@ -1,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>